1pub mod fuzzy;
5pub mod view;
6
7use gw_parse::LogRow;
8use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
9
10use crate::style::sanitize;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum Scope {
14 Head,
15 All,
16 Branch(String),
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Filters {
21 pub scope: Scope,
22 pub since: Option<String>,
23 pub until: Option<String>,
24 pub author: Option<String>,
25 pub user_args: Vec<String>,
27}
28
29impl Filters {
30 pub fn new(user_args: Vec<String>) -> Self {
31 Self { scope: Scope::Head, since: None, until: None, author: None, user_args }
32 }
33
34 pub fn git_args(&self) -> Vec<String> {
36 let mut a = Vec::new();
37 let user_rev = self.user_args.iter().take_while(|a| *a != "--").any(|a| !a.starts_with('-'));
39 match &self.scope {
40 Scope::Head if user_rev => {}
41 Scope::Head => a.push("HEAD".into()),
42 Scope::All => a.push("--all".into()),
43 Scope::Branch(b) if b.starts_with('-') => a.push("HEAD".into()),
45 Scope::Branch(b) => a.push(b.clone()),
46 }
47 if let Some(s) = &self.since {
48 a.push(format!("--since={s}"));
49 }
50 if let Some(u) = &self.until {
51 a.push(format!("--until={u}"));
52 }
53 if let Some(au) = &self.author {
54 a.push(format!("--author={au}"));
55 }
56 a.extend(self.user_args.iter().cloned());
57 a
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum ResetMode {
63 Soft,
64 Mixed,
65 Hard,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum Action {
70 CherryPick(String),
71 Revert(String),
72 Checkout(String),
73 NewBranch { name: String, sha: String },
74 Tag { name: String, sha: String },
75 Reset(ResetMode, String),
76}
77
78impl Action {
79 pub fn git_args(&self) -> Vec<String> {
80 let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect();
81 match self {
82 Action::CherryPick(s) => v(&["cherry-pick", s]),
83 Action::Revert(s) => v(&["revert", s]),
84 Action::Checkout(s) => v(&["checkout", "--detach", s]),
85 Action::NewBranch { name, sha } => v(&["branch", "--", name, sha]),
86 Action::Tag { name, sha } => v(&["tag", "--", name, sha]),
87 Action::Reset(m, s) => {
88 let flag = match m {
89 ResetMode::Soft => "--soft",
90 ResetMode::Mixed => "--mixed",
91 ResetMode::Hard => "--hard",
92 };
93 v(&["reset", flag, s])
94 }
95 }
96 }
97
98 pub fn needs_terminal(&self) -> bool {
101 !matches!(self, Action::NewBranch { .. } | Action::Tag { .. })
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum Effect {
107 None,
108 Quit,
109 Reload,
111 LoadFiles(String),
112 LoadDiff { sha: String, path: String },
113 LoadBranches,
114 LoadAuthors,
115 ValidateDate { since: String, until: String },
116 PrepareReset(ResetMode, String),
117 Run(Action),
118 Copy(String),
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum Focus {
123 List,
124 Sidebar,
125 Diff,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct FileEntry {
130 pub code: char,
131 pub path: String,
132 pub orig: Option<String>,
133 pub stat: Option<Option<(u64, u64)>>,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct Diff {
139 pub sha: String,
140 pub path: String,
141 pub lines: Vec<String>,
142 pub scroll: usize,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum NameKind {
147 Branch,
148 Tag,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct Picker {
154 pub input: String,
155 pub items: Vec<String>,
156 pub shown: Vec<usize>,
157 pub sel: usize,
158}
159
160impl Picker {
161 pub fn new(items: Vec<String>) -> Self {
162 let mut p = Self { input: String::new(), shown: Vec::new(), items, sel: 0 };
163 p.refilter();
164 p
165 }
166
167 fn refilter(&mut self) {
168 let mut scored: Vec<(i64, usize)> = self
169 .items
170 .iter()
171 .enumerate()
172 .filter_map(|(i, it)| if self.input.is_empty() { Some((0, i)) } else { fuzzy::score(&self.input, &[it]).map(|(s, _)| (s, i)) })
173 .collect();
174 scored.sort_by_key(|(s, i)| (std::cmp::Reverse(*s), *i));
175 self.shown = scored.into_iter().map(|(_, i)| i).collect();
176 self.sel = 0;
177 }
178
179 pub fn selected(&self) -> Option<&str> {
180 self.shown.get(self.sel).map(|&i| self.items[i].as_str())
181 }
182
183 fn key(&mut self, k: KeyEvent) -> bool {
185 match k.code {
186 KeyCode::Up => self.sel = self.sel.saturating_sub(1),
187 KeyCode::Down => self.sel = (self.sel + 1).min(self.shown.len().saturating_sub(1)),
188 KeyCode::Backspace => {
189 self.input.pop();
190 self.refilter();
191 }
192 KeyCode::Char(c) if !k.modifiers.contains(KeyModifiers::CONTROL) => {
193 self.input.push(c);
194 self.refilter();
195 }
196 _ => return false,
197 }
198 true
199 }
200}
201
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub enum Overlay {
204 None,
205 Search,
206 Branch(Picker),
207 Author(Picker),
209 Date { since: String, until: String, field: usize, error: Option<String> },
210 Actions,
211 ResetMode,
212 Name { kind: NameKind, input: String, error: Option<String> },
213 Confirm { action: Action, lines: Vec<String>, need_y: bool },
214}
215
216pub struct App {
217 pub rows: Vec<LogRow>,
218 pub view: Vec<usize>,
220 pub marks: Vec<Vec<Vec<usize>>>,
222 pub sel: usize,
223 pub scroll: usize,
224 pub loading: bool,
225 pub filters: Filters,
226 pub search: String,
227 pub focus: Focus,
228 pub files: Option<(String, Vec<FileEntry>)>,
229 pub file_sel: usize,
230 pub file_scroll: usize,
231 pub diff: Option<Diff>,
232 pub overlay: Overlay,
233 pub status: Option<(String, bool)>,
235 pub head: String,
237 pub remotes: Vec<String>,
238 pub now: i64,
240 pub width: u16,
241 pub height: u16,
242 reselect: Option<String>,
243}
244
245impl App {
246 pub fn new(filters: Filters, head: String, remotes: Vec<String>, now: i64) -> Self {
247 Self {
248 rows: Vec::new(),
249 view: Vec::new(),
250 marks: Vec::new(),
251 sel: 0,
252 scroll: 0,
253 loading: true,
254 filters,
255 search: String::new(),
256 focus: Focus::List,
257 files: None,
258 file_sel: 0,
259 file_scroll: 0,
260 diff: None,
261 overlay: Overlay::None,
262 status: None,
263 head,
264 remotes,
265 now,
266 width: 80,
267 height: 24,
268 reselect: None,
269 }
270 }
271
272 pub fn resize(&mut self, w: u16, h: u16) {
273 self.width = w;
274 self.height = h;
275 self.ensure_visible();
276 }
277
278 pub fn body_height(&self) -> usize {
280 let panel = if matches!(self.overlay, Overlay::Actions | Overlay::ResetMode) { 3 } else { 0 };
281 (self.height as usize).saturating_sub(3 + panel + 2).max(1)
283 }
284
285 pub fn wide(&self) -> bool {
287 self.width >= 100
288 }
289
290 pub fn begin_load(&mut self) {
294 self.reselect = self.selected_hash().map(str::to_string);
295 self.rows.clear();
296 self.view.clear();
297 self.marks.clear();
298 self.sel = 0;
299 self.scroll = 0;
300 self.loading = true;
301 }
302
303 pub fn push_rows(&mut self, rows: Vec<LogRow>) {
304 let start = self.rows.len();
305 self.rows.extend(rows);
306 if self.search.is_empty() {
307 self.view.extend(start..self.rows.len());
308 self.marks.resize(self.view.len(), Vec::new());
309 } else {
310 self.refilter();
311 }
312 if let Some(h) = self.reselect.clone()
313 && let Some(p) = self.view.iter().position(|&i| self.rows[i].commit.as_ref().is_some_and(|c| c.hash == h))
314 {
315 self.sel = p;
316 self.reselect = None;
317 self.ensure_visible();
318 return;
319 }
320 if !self.is_commit(self.sel) {
321 self.move_by(1);
322 }
323 }
324
325 pub fn finish_load(&mut self) {
326 self.loading = false;
327 self.reselect = None;
328 }
329
330 fn is_commit(&self, pos: usize) -> bool {
333 self.view.get(pos).is_some_and(|&i| self.rows[i].commit.is_some())
334 }
335
336 pub fn selected_hash(&self) -> Option<&str> {
337 self.view.get(self.sel).and_then(|&i| self.rows[i].commit.as_ref()).map(|c| c.hash.as_str())
338 }
339
340 pub fn selected_short(&self) -> Option<&str> {
341 self.view.get(self.sel).and_then(|&i| self.rows[i].commit.as_ref()).map(|c| c.short.as_str())
342 }
343
344 fn move_by(&mut self, delta: isize) {
346 if self.view.is_empty() {
347 return;
348 }
349 let last = self.view.len() - 1;
350 let mut p = (self.sel as isize + delta).clamp(0, last as isize) as usize;
351 if delta.abs() == 1 {
352 while !self.is_commit(p) && p > 0 && p < last {
354 p = (p as isize + delta) as usize;
355 }
356 }
357 if !self.is_commit(p) {
358 let fwd = (p..=last).find(|&q| self.is_commit(q));
359 let back = (0..p).rev().find(|&q| self.is_commit(q));
360 p = if delta < 0 { back.or(fwd) } else { fwd.or(back) }.unwrap_or(self.sel);
361 }
362 self.sel = p;
363 self.ensure_visible();
364 }
365
366 fn ensure_visible(&mut self) {
367 let h = self.body_height();
368 if self.sel < self.scroll {
369 self.scroll = self.sel;
370 } else if self.sel >= self.scroll + h {
371 self.scroll = self.sel + 1 - h;
372 }
373 }
374
375 fn refilter(&mut self) {
378 let keep = self.selected_hash().map(str::to_string);
379 self.view.clear();
380 self.marks.clear();
381 if self.search.trim().is_empty() {
382 self.view.extend(0..self.rows.len());
383 self.marks.resize(self.view.len(), Vec::new());
384 } else {
385 let mut hits: Vec<(i64, usize, Vec<Vec<usize>>)> = self
386 .rows
387 .iter()
388 .enumerate()
389 .filter_map(|(i, r)| {
390 let c = r.commit.as_ref()?;
391 let (author, subject, refs) = (sanitize(&c.author), sanitize(&c.subject), sanitize(&c.refs.join(", ")));
392 fuzzy::score(&self.search, &[&c.hash, &author, &subject, &refs]).map(|(s, m)| (s, i, m))
394 })
395 .collect();
396 hits.sort_by_key(|(s, i, _)| (std::cmp::Reverse(*s), *i));
397 for (_, i, m) in hits {
398 self.view.push(i);
399 self.marks.push(m);
400 }
401 }
402 self.sel = keep.and_then(|h| self.view.iter().position(|&i| self.rows[i].commit.as_ref().is_some_and(|c| c.hash == h))).unwrap_or(0);
403 self.scroll = 0;
404 if !self.is_commit(self.sel) {
405 self.move_by(1);
406 }
407 self.ensure_visible();
408 }
409
410 pub fn set_search(&mut self, q: &str) {
411 self.search = q.to_string();
412 self.refilter();
413 }
414
415 pub fn set_files(&mut self, sha: String, files: Vec<FileEntry>) {
418 self.files = Some((sha, files));
419 self.file_sel = 0;
420 self.file_scroll = 0;
421 }
422
423 pub fn files_wanted(&self) -> Option<&str> {
425 let sha = self.selected_hash()?;
426 match &self.files {
427 Some((s, _)) if s == sha => None,
428 _ => Some(sha),
429 }
430 }
431
432 pub fn set_diff(&mut self, sha: String, path: String, lines: Vec<String>) {
433 self.diff = Some(Diff { sha, path, lines, scroll: 0 });
434 self.focus = Focus::Diff;
435 }
436
437 pub fn open_branches(&mut self, names: Vec<String>) {
438 self.overlay = Overlay::Branch(Picker::new(names));
439 }
440
441 pub fn open_authors(&mut self, authors: Vec<(u32, String)>) {
442 self.overlay = Overlay::Author(Picker::new(authors.into_iter().map(|(_, a)| a).collect()));
443 }
444
445 pub fn date_error(&mut self, msg: String) {
446 if let Overlay::Date { error, .. } = &mut self.overlay {
447 *error = Some(msg);
448 }
449 }
450
451 pub fn apply_date(&mut self, since: String, until: String) {
453 let opt = |s: String| (!s.trim().is_empty()).then(|| s.trim().to_string());
454 self.filters.since = opt(since);
455 self.filters.until = opt(until);
456 self.overlay = Overlay::None;
457 }
458
459 pub fn confirm_reset(&mut self, mode: ResetMode, sha: String, branch: String, at_risk: Vec<String>) {
460 let short = sha.chars().take(7).collect::<String>();
461 let name = match mode {
462 ResetMode::Soft => "soft",
463 ResetMode::Mixed => "mixed",
464 ResetMode::Hard => "hard",
465 };
466 let mut lines = vec![format!("Reset {} to {short} ({name})?", sanitize(&branch))];
467 let hard = mode == ResetMode::Hard;
468 if hard {
469 if at_risk.is_empty() {
470 lines.push("No uncommitted changes will be lost.".into());
471 } else {
472 lines.push("Uncommitted changes in these files will be LOST:".into());
473 lines.extend(at_risk.iter().take(8).map(|f| format!(" {}", sanitize(f))));
474 if at_risk.len() > 8 {
475 lines.push(format!(" … and {} more", at_risk.len() - 8));
476 }
477 }
478 }
479 lines.push(if hard { "Press y to reset, Esc to cancel".into() } else { "Press Enter or y to reset, Esc to cancel".into() });
480 self.overlay = Overlay::Confirm { action: Action::Reset(mode, sha), lines, need_y: hard };
481 }
482
483 pub fn action_result(&mut self, ok: bool, msg: String) {
485 if let (false, Overlay::Name { error, .. }) = (ok, &mut self.overlay) {
486 *error = Some(msg);
487 return;
488 }
489 self.overlay = Overlay::None;
490 self.status = Some((msg, !ok));
491 }
492
493 pub fn key(&mut self, k: KeyEvent) -> Effect {
496 if k.code == KeyCode::Char('c') && k.modifiers.contains(KeyModifiers::CONTROL) {
497 return Effect::Quit;
498 }
499 if self.overlay != Overlay::None {
500 return self.overlay_key(k);
501 }
502 self.status = None;
503 match self.focus {
504 Focus::Diff => self.diff_key(k),
505 Focus::List | Focus::Sidebar => self.main_key(k),
506 }
507 }
508
509 fn page(&self) -> isize {
510 self.body_height().max(1) as isize
511 }
512
513 fn main_key(&mut self, k: KeyEvent) -> Effect {
514 let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
515 if self.focus == Focus::Sidebar {
516 let n = self.files.as_ref().map_or(0, |f| f.1.len());
517 let page = self.page();
518 let sel = self.file_sel;
519 let step = |d: isize| (sel as isize + d).clamp(0, n.saturating_sub(1) as isize) as usize;
520 let target = match k.code {
521 KeyCode::Up | KeyCode::Char('k') => Some(step(-1)),
522 KeyCode::Down | KeyCode::Char('j') => Some(step(1)),
523 KeyCode::PageUp => Some(step(-page)),
524 KeyCode::PageDown => Some(step(page)),
525 KeyCode::Home | KeyCode::Char('g') => Some(0),
526 KeyCode::End | KeyCode::Char('G') => Some(n.saturating_sub(1)),
527 _ => None,
528 };
529 if let Some(t) = target {
530 self.file_sel = t;
531 let h = self.body_height().saturating_sub(1).max(1);
532 if self.file_sel < self.file_scroll {
533 self.file_scroll = self.file_sel;
534 } else if self.file_sel >= self.file_scroll + h {
535 self.file_scroll = self.file_sel + 1 - h;
536 }
537 return Effect::None;
538 }
539 if k.code == KeyCode::Enter {
540 if let (Some((sha, files)), true) = (&self.files, n > 0) {
541 return Effect::LoadDiff { sha: sha.clone(), path: files[self.file_sel].path.clone() };
542 }
543 return Effect::None;
544 }
545 }
546 match k.code {
547 KeyCode::Char('q') => return Effect::Quit,
548 KeyCode::Tab | KeyCode::BackTab => {
549 self.focus = if self.focus == Focus::List { Focus::Sidebar } else { Focus::List };
550 }
551 KeyCode::Up | KeyCode::Char('k') => self.move_by(-1),
552 KeyCode::Down | KeyCode::Char('j') => self.move_by(1),
553 KeyCode::PageUp => self.move_by(-self.page()),
554 KeyCode::PageDown => self.move_by(self.page()),
555 KeyCode::Home | KeyCode::Char('g') => self.move_by(-(self.view.len() as isize)),
556 KeyCode::End | KeyCode::Char('G') => self.move_by(self.view.len() as isize),
557 KeyCode::Enter => self.focus = Focus::Sidebar,
558 KeyCode::Esc if !self.search.is_empty() => self.set_search(""),
559 KeyCode::Char('/') => self.overlay = Overlay::Search,
560 KeyCode::Char('f') if ctrl => self.overlay = Overlay::Search,
561 KeyCode::Char('a') => {
562 self.filters.scope = if self.filters.scope == Scope::All { Scope::Head } else { Scope::All };
563 return Effect::Reload;
564 }
565 KeyCode::Char('b') => return Effect::LoadBranches,
566 KeyCode::Char('u') => return Effect::LoadAuthors,
567 KeyCode::Char('d') => {
568 self.overlay = Overlay::Date {
569 since: self.filters.since.clone().unwrap_or_default(),
570 until: self.filters.until.clone().unwrap_or_default(),
571 field: 0,
572 error: None,
573 }
574 }
575 KeyCode::Char('x') => {
576 let f = Filters::new(std::mem::take(&mut self.filters.user_args));
577 self.filters = f;
578 self.search.clear();
579 return Effect::Reload;
580 }
581 KeyCode::Char(' ') if self.selected_hash().is_some() => self.overlay = Overlay::Actions,
582 _ => {}
583 }
584 Effect::None
585 }
586
587 fn diff_key(&mut self, k: KeyEvent) -> Effect {
588 let page = self.page();
589 let Some(d) = &mut self.diff else {
590 self.focus = Focus::Sidebar;
591 return Effect::None;
592 };
593 let max = d.lines.len().saturating_sub(1) as isize;
594 let mut scroll = |delta: isize| d.scroll = (d.scroll as isize + delta).clamp(0, max) as usize;
595 match k.code {
596 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Backspace => {
597 self.diff = None;
598 self.focus = Focus::Sidebar;
599 }
600 KeyCode::Up | KeyCode::Char('k') => scroll(-1),
601 KeyCode::Down | KeyCode::Char('j') => scroll(1),
602 KeyCode::PageUp => scroll(-page),
603 KeyCode::PageDown | KeyCode::Char(' ') => scroll(page),
604 KeyCode::Home | KeyCode::Char('g') => scroll(-max - 1),
605 KeyCode::End | KeyCode::Char('G') => scroll(max),
606 _ => {}
607 }
608 Effect::None
609 }
610
611 fn overlay_key(&mut self, k: KeyEvent) -> Effect {
612 let esc = k.code == KeyCode::Esc;
613 let enter = k.code == KeyCode::Enter;
614 let sha = self.selected_hash().map(str::to_string).unwrap_or_default();
615 match &mut self.overlay {
616 Overlay::None => Effect::None,
617 Overlay::Search => {
618 match k.code {
619 KeyCode::Esc => {
620 self.overlay = Overlay::None;
621 self.set_search("");
622 }
623 KeyCode::Enter => self.overlay = Overlay::None,
624 KeyCode::Backspace => {
625 let mut q = self.search.clone();
626 q.pop();
627 self.set_search(&q);
628 }
629 KeyCode::Up => self.move_by(-1),
630 KeyCode::Down => self.move_by(1),
631 KeyCode::Char(c) if !k.modifiers.contains(KeyModifiers::CONTROL) => {
632 let q = format!("{}{c}", self.search);
633 self.set_search(&q);
634 }
635 _ => {}
636 }
637 Effect::None
638 }
639 Overlay::Branch(p) => {
640 if esc {
641 self.overlay = Overlay::None;
642 } else if enter {
643 if let Some(b) = p.selected().map(str::to_string) {
644 self.filters.scope = Scope::Branch(b);
645 self.overlay = Overlay::None;
646 return Effect::Reload;
647 }
648 } else {
649 p.key(k);
650 }
651 Effect::None
652 }
653 Overlay::Author(p) => {
654 if esc {
655 self.overlay = Overlay::None;
656 } else if enter {
657 let pattern = match p.selected() {
658 Some(a) => regex_escape(a),
659 None if !p.input.trim().is_empty() => p.input.trim().to_string(),
660 None => return Effect::None,
661 };
662 self.filters.author = Some(pattern);
663 self.overlay = Overlay::None;
664 return Effect::Reload;
665 } else {
666 p.key(k);
667 }
668 Effect::None
669 }
670 Overlay::Date { since, until, field, error } => {
671 match k.code {
672 KeyCode::Esc => self.overlay = Overlay::None,
673 KeyCode::Enter => return Effect::ValidateDate { since: since.clone(), until: until.clone() },
674 KeyCode::Tab | KeyCode::BackTab | KeyCode::Up | KeyCode::Down => *field = 1 - *field,
675 KeyCode::Backspace => {
676 if *field == 0 { since.pop() } else { until.pop() };
677 *error = None;
678 }
679 KeyCode::Char(c) if !k.modifiers.contains(KeyModifiers::CONTROL) => {
680 if *field == 0 { since.push(c) } else { until.push(c) };
681 *error = None;
682 }
683 _ => {}
684 }
685 Effect::None
686 }
687 Overlay::Actions => {
688 self.overlay = Overlay::None;
689 let short = self.selected_short().unwrap_or_default().to_string();
690 match k.code {
691 KeyCode::Char('p') => return Effect::Run(Action::CherryPick(sha)),
692 KeyCode::Char('v') => return Effect::Run(Action::Revert(sha)),
693 KeyCode::Char('o') => {
694 self.overlay = Overlay::Confirm {
695 action: Action::Checkout(sha),
696 lines: vec![format!("Check out {short}?"), "HEAD will be detached (not on any branch).".into(), "Press Enter or y to continue, Esc to cancel".into()],
697 need_y: false,
698 }
699 }
700 KeyCode::Char('n') => self.overlay = Overlay::Name { kind: NameKind::Branch, input: String::new(), error: None },
701 KeyCode::Char('t') => self.overlay = Overlay::Name { kind: NameKind::Tag, input: String::new(), error: None },
702 KeyCode::Char('r') => self.overlay = Overlay::ResetMode,
703 KeyCode::Char('y') => {
704 self.status = Some((format!("Copied {short}"), false));
705 return Effect::Copy(sha);
706 }
707 _ => {}
708 }
709 Effect::None
710 }
711 Overlay::ResetMode => {
712 self.overlay = Overlay::None;
713 let mode = match k.code {
714 KeyCode::Char('s') => ResetMode::Soft,
715 KeyCode::Char('m') => ResetMode::Mixed,
716 KeyCode::Char('h') => ResetMode::Hard,
717 _ => return Effect::None,
718 };
719 Effect::PrepareReset(mode, sha)
720 }
721 Overlay::Name { kind, input, error } => {
722 match k.code {
723 KeyCode::Esc => self.overlay = Overlay::None,
724 KeyCode::Enter if !input.trim().is_empty() => {
725 let name = input.trim().to_string();
726 return Effect::Run(match kind {
727 NameKind::Branch => Action::NewBranch { name, sha },
728 NameKind::Tag => Action::Tag { name, sha },
729 });
730 }
731 KeyCode::Backspace => {
732 input.pop();
733 *error = None;
734 }
735 KeyCode::Char(c) if !k.modifiers.contains(KeyModifiers::CONTROL) => {
736 input.push(c);
737 *error = None;
738 }
739 _ => {}
740 }
741 Effect::None
742 }
743 Overlay::Confirm { action, need_y, .. } => {
744 let go = k.code == KeyCode::Char('y') || (enter && !*need_y);
745 let action = action.clone();
746 if go || esc {
747 self.overlay = Overlay::None;
748 }
749 if go { Effect::Run(action) } else { Effect::None }
750 }
751 }
752 }
753
754 pub fn hotkeys(&self) -> Vec<(&'static str, &'static str)> {
756 match (&self.overlay, self.focus) {
757 (Overlay::Search, _) => vec![("Enter", "Keep"), ("Esc", "Clear"), ("↑↓", "Move")],
758 (Overlay::Branch(_) | Overlay::Author(_), _) => vec![("Enter", "Apply"), ("↑↓", "Select"), ("Esc", "Cancel")],
759 (Overlay::Date { .. }, _) => vec![("Enter", "Apply"), ("Tab", "Field"), ("Esc", "Cancel")],
760 (Overlay::Name { .. }, _) => vec![("Enter", "Create"), ("Esc", "Cancel")],
761 (Overlay::Confirm { need_y: true, .. }, _) => vec![("y", "Confirm"), ("Esc", "Cancel")],
762 (Overlay::Confirm { .. }, _) => vec![("Enter", "Confirm"), ("Esc", "Cancel")],
763 (Overlay::Actions | Overlay::ResetMode, _) => vec![("Esc", "Close")],
764 (Overlay::None, Focus::Diff) => vec![("j/k", "Scroll"), ("PgUp/PgDn", "Page"), ("g/G", "Top/End"), ("Esc", "Back")],
765 (Overlay::None, Focus::Sidebar) => {
766 vec![("j/k", "Move"), ("Enter", "Diff"), ("Tab", "Commits"), ("Space", "Actions"), ("q", "Quit")]
767 }
768 (Overlay::None, Focus::List) => vec![
769 ("j/k", "Move"),
770 ("Tab", "Files"),
771 ("/", "Search"),
772 ("a", "All"),
773 ("b", "Branch"),
774 ("d", "Date"),
775 ("u", "Author"),
776 ("x", "Clear"),
777 ("Space", "Actions"),
778 ("q", "Quit"),
779 ],
780 }
781 }
782
783 pub fn header(&self) -> String {
785 let scope = match &self.filters.scope {
786 Scope::Head => "current branch".to_string(),
787 Scope::All => "all branches".to_string(),
788 Scope::Branch(b) => format!("branch {}", sanitize(b)),
789 };
790 let mut parts = vec![sanitize(&self.head), scope];
791 if let Some(s) = &self.filters.since {
792 parts.push(format!("since {}", sanitize(s)));
793 }
794 if let Some(u) = &self.filters.until {
795 parts.push(format!("until {}", sanitize(u)));
796 }
797 if let Some(a) = &self.filters.author {
798 parts.push(format!("author {}", sanitize(a)));
799 }
800 if !self.search.is_empty() {
801 parts.push(format!("/{}", sanitize(&self.search)));
802 }
803 let commits = self.rows.iter().filter(|r| r.commit.is_some()).count();
804 let noun = if commits == 1 { "commit" } else { "commits" };
805 parts.push(if self.loading { format!("{commits} {noun}, loading…") } else { format!("{commits} {noun}") });
806 parts.join(" │ ")
807 }
808}
809
810fn regex_escape(s: &str) -> String {
812 let mut out = String::with_capacity(s.len());
813 for c in s.chars() {
814 if ".[]()*+?^$|{}\\".contains(c) {
815 out.push('\\');
816 }
817 out.push(c);
818 }
819 out
820}
821
822#[cfg(test)]
823mod tests {
824 use super::*;
825 use gw_parse::parse_graph_line;
826
827 fn key(c: KeyCode) -> KeyEvent {
828 KeyEvent::new(c, KeyModifiers::NONE)
829 }
830
831 fn app(fixture: &str) -> App {
832 let p = format!("{}/../gw-parse/tests/fixtures/{fixture}", env!("CARGO_MANIFEST_DIR"));
833 let text = std::fs::read_to_string(p).unwrap();
834 let mut a = App::new(Filters::new(vec![]), "main".into(), vec!["origin".into()], 1_793_751_908);
835 a.resize(120, 12);
836 a.push_rows(text.lines().map(parse_graph_line).collect());
837 a.finish_load();
838 a
839 }
840
841 #[test]
842 fn navigation_skips_connectors() {
843 let mut a = app("log-merge.txt");
844 assert_eq!(a.selected_short(), Some("3c5ecf2"));
845 a.key(key(KeyCode::Char('j')));
846 assert_eq!(a.selected_short(), Some("708b13e"), "connector line `|\\` skipped");
847 a.key(key(KeyCode::Char('G')));
848 assert_eq!(a.selected_short(), Some("389c790"));
849 a.key(key(KeyCode::Char('k')));
850 assert_eq!(a.selected_short(), Some("a1bc23a"));
851 a.key(key(KeyCode::Char('g')));
852 assert_eq!(a.selected_short(), Some("3c5ecf2"));
853 }
854
855 #[test]
856 fn page_moves_and_scrolls() {
857 let mut a = app("log-all.txt");
858 let h = a.body_height();
859 a.key(key(KeyCode::PageDown));
860 assert!(a.sel >= h - 1 && a.is_commit(a.sel), "sel={} h={h}", a.sel);
861 assert!(a.scroll > 0);
862 a.key(key(KeyCode::PageUp));
863 a.key(key(KeyCode::PageUp));
864 assert_eq!(a.sel, 0);
865 }
866
867 #[test]
868 fn tab_switches_focus_and_sidebar_navigation() {
869 let mut a = app("log-linear.txt");
870 a.set_files("x".into(), vec![FileEntry { code: 'M', path: "a".into(), orig: None, stat: None }; 3]);
871 a.key(key(KeyCode::Tab));
872 assert_eq!(a.focus, Focus::Sidebar);
873 a.key(key(KeyCode::Char('j')));
874 a.key(key(KeyCode::Char('j')));
875 a.key(key(KeyCode::Char('j')));
876 assert_eq!(a.file_sel, 2, "clamped to last file");
877 let e = a.key(key(KeyCode::Enter));
878 assert_eq!(e, Effect::LoadDiff { sha: "x".into(), path: "a".into() });
879 a.set_diff("x".into(), "a".into(), vec!["+x".into()]);
880 assert_eq!(a.focus, Focus::Diff);
881 a.key(key(KeyCode::Esc));
882 assert_eq!((a.focus, a.file_sel), (Focus::Sidebar, 2), "back to the same file");
883 a.key(key(KeyCode::Tab));
884 assert_eq!(a.focus, Focus::List);
885 }
886
887 #[test]
888 fn scope_toggle_changes_git_args() {
889 let mut a = app("log-linear.txt");
890 assert_eq!(a.filters.git_args(), vec!["HEAD"]);
891 assert_eq!(a.key(key(KeyCode::Char('a'))), Effect::Reload);
892 assert_eq!(a.filters.git_args(), vec!["--all"]);
893 assert!(a.header().contains("all branches"));
894 a.key(key(KeyCode::Char('a')));
895 assert_eq!(a.filters.git_args(), vec!["HEAD"]);
896 let f = Filters { since: Some("2 weeks ago".into()), author: Some("x".into()), ..Filters::new(vec!["--".into(), "src/".into()]) };
897 assert_eq!(f.git_args(), vec!["HEAD", "--since=2 weeks ago", "--author=x", "--", "src/"]);
898 assert_eq!(Filters::new(vec!["main..feat".into()]).git_args(), vec!["main..feat"], "user revision replaces HEAD");
899 let evil = Filters { scope: Scope::Branch("--output=/tmp/x".into()), ..Filters::new(vec![]) };
900 assert_eq!(evil.git_args(), vec!["HEAD"], "option-like ref is never passed to git");
901 }
902
903 #[test]
904 fn search_filters_and_esc_clears() {
905 let mut a = app("log-merge.txt");
906 a.key(key(KeyCode::Char('/')));
907 for c in "alice forms".chars() {
908 a.key(key(KeyCode::Char(c)));
909 }
910 assert_eq!(a.view.len(), 2);
911 assert!(a.view.iter().all(|&i| a.rows[i].commit.as_ref().unwrap().author == "Alice"));
912 a.key(key(KeyCode::Enter));
913 assert_eq!(a.overlay, Overlay::None);
914 assert_eq!(a.view.len(), 2, "Enter keeps the filter");
915 a.key(key(KeyCode::Esc));
916 assert_eq!(a.view.len(), a.rows.len());
917 }
918
919 #[test]
920 fn modal_flows() {
921 let mut a = app("log-linear.txt");
922 assert_eq!(a.key(key(KeyCode::Char('b'))), Effect::LoadBranches);
923 a.open_branches(vec!["main".into(), "release/1.2".into(), "origin/main".into()]);
924 for c in "rel".chars() {
925 a.key(key(KeyCode::Char(c)));
926 }
927 assert_eq!(a.key(key(KeyCode::Enter)), Effect::Reload);
928 assert_eq!(a.filters.scope, Scope::Branch("release/1.2".into()));
929
930 a.open_authors(vec![(3, "Alice <a+b@x.io>".into())]);
931 assert_eq!(a.key(key(KeyCode::Enter)), Effect::Reload);
932 assert_eq!(a.filters.author.as_deref(), Some(r"Alice <a\+b@x\.io>"));
933 a.open_authors(vec![(3, "Alice <a@x.io>".into())]);
934 for c in "zed@ex".chars() {
935 a.key(key(KeyCode::Char(c)));
936 }
937 a.key(key(KeyCode::Enter));
938 assert_eq!(a.filters.author.as_deref(), Some("zed@ex"), "unlisted author used as typed");
939
940 a.key(key(KeyCode::Char('d')));
941 for c in "2 weeks ago".chars() {
942 a.key(key(KeyCode::Char(c)));
943 }
944 assert_eq!(a.key(key(KeyCode::Enter)), Effect::ValidateDate { since: "2 weeks ago".into(), until: String::new() });
945 a.date_error("date not understood".into());
946 assert!(matches!(a.overlay, Overlay::Date { error: Some(_), .. }), "stays open on error");
947 a.apply_date("2 weeks ago".into(), String::new());
948 assert_eq!(a.filters.since.as_deref(), Some("2 weeks ago"));
949 assert_eq!(a.key(key(KeyCode::Char('x'))), Effect::Reload);
950 assert_eq!(a.filters, Filters::new(vec![]));
951 }
952
953 #[test]
954 fn actions_and_confirmations() {
955 let mut a = app("log-linear.txt");
956 let sha = a.selected_hash().unwrap().to_string();
957 a.key(key(KeyCode::Char(' ')));
958 assert_eq!(a.overlay, Overlay::Actions);
959 assert_eq!(a.key(key(KeyCode::Char('v'))), Effect::Run(Action::Revert(sha.clone())));
960
961 a.key(key(KeyCode::Char(' ')));
962 a.key(key(KeyCode::Char('r')));
963 assert_eq!(a.key(key(KeyCode::Char('h'))), Effect::PrepareReset(ResetMode::Hard, sha.clone()));
964 a.confirm_reset(ResetMode::Hard, sha.clone(), "main".into(), vec!["a.rs".into()]);
965 assert_eq!(a.key(key(KeyCode::Enter)), Effect::None, "hard reset needs y");
966 assert_eq!(a.key(key(KeyCode::Esc)), Effect::None);
967 assert_eq!(a.overlay, Overlay::None, "Esc cancels");
968 a.confirm_reset(ResetMode::Hard, sha.clone(), "main".into(), vec![]);
969 assert_eq!(a.key(key(KeyCode::Char('y'))), Effect::Run(Action::Reset(ResetMode::Hard, sha.clone())));
970
971 a.key(key(KeyCode::Char(' ')));
972 a.key(key(KeyCode::Char('t')));
973 for c in "v0.2.0".chars() {
974 a.key(key(KeyCode::Char(c)));
975 }
976 let e = a.key(key(KeyCode::Enter));
977 assert_eq!(e, Effect::Run(Action::Tag { name: "v0.2.0".into(), sha: sha.clone() }));
978 a.action_result(false, "fatal: tag exists".into());
979 assert!(matches!(a.overlay, Overlay::Name { error: Some(_), .. }), "error kept in modal");
980
981 a.overlay = Overlay::None;
982 a.key(key(KeyCode::Char(' ')));
983 assert_eq!(a.key(key(KeyCode::Char('y'))), Effect::Copy(sha.clone()));
984 assert_eq!(Action::Checkout(sha.clone()).git_args(), vec!["checkout", "--detach", sha.as_str()]);
985 assert_eq!(Action::Tag { name: "-x".into(), sha: "s".into() }.git_args(), vec!["tag", "--", "-x", "s"]);
986 }
987}