Skip to main content

gw_render/browser/
mod.rs

1//! `gw log` browser: pure state + key handling. IO is requested through [`Effect`]s,
2//! which the binary runs and feeds back through the `set_*` / `*_result` methods.
3
4pub 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    /// Arguments the user passed to `gw log`, appended unchanged.
26    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    /// git log arguments for the current filters (format and graph flags not included).
35    pub fn git_args(&self) -> Vec<String> {
36        let mut a = Vec::new();
37        // A revision given by the user replaces the default HEAD scope.
38        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            // A ref name starting with '-' would be parsed as an option: never pass one.
44            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    /// Actions that may open an editor or print conflicts get the real terminal;
99    /// branch/tag creation runs captured so errors can stay in their modal.
100    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    /// Restart `git log` with the current filters.
110    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    /// `None`: no counts known; `Some(None)`: binary.
134    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/// A filterable list modal (branches or authors).
152#[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    /// Text editing and selection keys; returns true if handled.
184    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    /// `labels[i]` = "Name <email>" as shown; items carry the same text with counts.
208    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    /// Indices into `rows` currently shown.
219    pub view: Vec<usize>,
220    /// Search match positions per shown row: [short hash, author, subject, refs].
221    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    /// (message, is_error)
234    pub status: Option<(String, bool)>,
235    /// Header label for HEAD: branch name or `detached at <sha>`.
236    pub head: String,
237    pub remotes: Vec<String>,
238    /// Unix time used for relative ages.
239    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    /// Rows available for the commit list / sidebar / diff.
279    pub fn body_height(&self) -> usize {
280        let panel = if matches!(self.overlay, Overlay::Actions | Overlay::ResetMode) { 3 } else { 0 };
281        // header + status + hotkeys, plus the pane borders
282        (self.height as usize).saturating_sub(3 + panel + 2).max(1)
283    }
284
285    /// Wide enough to show the sidebar beside the list.
286    pub fn wide(&self) -> bool {
287        self.width >= 100
288    }
289
290    // ---- loading ----
291
292    /// Starts a fresh load (after a filter change); keeps the selected commit if it reappears.
293    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    // ---- selection ----
331
332    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    /// Moves by `delta` lines, landing on a commit row (searching forward, then backward).
345    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            // single steps skip connector lines in the direction of travel
353            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    // ---- search ----
376
377    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                    // The full hash is matched, but highlights are shown on the short hash.
393                    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    // ---- results fed back by the binary ----
416
417    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    /// The selected commit's file list is missing or stale.
424    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    /// Valid dates: apply them. Caller then reloads.
452    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    /// Result of a git action. Name modals stay open on error.
484    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    // ---- keys ----
494
495    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    /// Hotkeys for the bottom bar in the current context: (key, label).
755    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    /// Header text: HEAD, scope, and active filters.
784    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
810/// Escapes regex metacharacters so a listed author matches literally in `--author`.
811fn 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}