git_iblame/ui/
cli.rs

1use std::{
2    io::stdout,
3    path::{Path, PathBuf},
4    time::Duration,
5};
6
7use clap::Parser;
8use crossterm::{clipboard::CopyToClipboard, cursor, execute, style, terminal};
9use git2::Oid;
10use log::debug;
11
12use crate::{blame::FileHistory, extensions::TerminalRawModeScope};
13
14use super::*;
15
16/// Interactive enhanced `git blame` command line tool.
17#[derive(Debug, Default, Parser)]
18#[command(version, about)]
19struct Args {
20    /// Use git2 to compute the commit diff.
21    #[arg(long, default_value_t = false)]
22    diff_git2: bool,
23
24    /// Path of the file to annotate the history.
25    path: PathBuf,
26}
27
28#[derive(Debug, Default)]
29/// The `git-iblame` command line interface.
30/// # Examples
31/// ```no_run
32/// use git_iblame::ui::Cli;
33///
34/// # use std::path::PathBuf;
35/// fn main() -> anyhow::Result<()> {
36///   let path = PathBuf::from("path/to/file");
37///   let mut cli: Cli = Cli::new(&path);
38///   cli.run()
39/// }
40/// ```
41pub struct Cli {
42    path: PathBuf,
43    history: Vec<Oid>,
44    last_search: Option<String>,
45}
46
47impl Cli {
48    pub fn new_from_args() -> Self {
49        let args = Args::parse();
50        if args.diff_git2 {
51            crate::blame::FileCommit::use_git2();
52        }
53        Self {
54            path: args.path,
55            ..Default::default()
56        }
57    }
58
59    pub fn new(path: &Path) -> Self {
60        Self {
61            path: path.to_path_buf(),
62            ..Default::default()
63        }
64    }
65
66    /// Run the `git-iblame` command line interface.
67    pub fn run(&mut self) -> anyhow::Result<()> {
68        let mut history = FileHistory::new(&self.path);
69        history.read_start()?;
70
71        let mut renderer = BlameRenderer::new(history)?;
72        let size = terminal::size()?;
73        renderer.set_view_size((size.0, size.1 - 1));
74
75        let mut ui = CommandUI::new();
76        let mut out = stdout();
77        let mut terminal_raw_mode = TerminalRawModeScope::new(true)?;
78        loop {
79            let result = renderer.render(&mut out);
80            ui.set_result(result);
81            let command_rows = renderer.rendered_rows();
82
83            if renderer.history().is_reading() {
84                ui.timeout = Duration::from_millis(1000);
85                if matches!(ui.prompt, CommandPrompt::None) {
86                    ui.prompt = CommandPrompt::Loading;
87                }
88            } else {
89                ui.timeout = Duration::ZERO;
90                if matches!(ui.prompt, CommandPrompt::Loading) {
91                    ui.prompt = CommandPrompt::None;
92                }
93            }
94            let command = ui.read(command_rows)?;
95            match command {
96                Command::Quit => break,
97                Command::Timeout => {}
98                _ => ui.prompt = CommandPrompt::None,
99            }
100            let result = self.handle_command(command, &mut renderer, &mut ui);
101            ui.set_result(result);
102        }
103
104        terminal_raw_mode.reset()?;
105        Ok(())
106    }
107
108    fn handle_command(
109        &mut self,
110        command: Command,
111        renderer: &mut BlameRenderer,
112        ui: &mut CommandUI,
113    ) -> anyhow::Result<()> {
114        let mut out = stdout();
115        match command {
116            Command::PrevLine => renderer.move_to_prev_line_by(1),
117            Command::NextLine => renderer.move_to_next_line_by(1),
118            // Command::PrevDiff => renderer.move_to_prev_diff(),
119            // Command::NextDiff => renderer.move_to_next_diff(),
120            Command::PrevPage => renderer.move_to_prev_page(),
121            Command::NextPage => renderer.move_to_next_page(),
122            Command::FirstLine => renderer.move_to_first_line(),
123            Command::LastLine => renderer.move_to_last_line(),
124            Command::LineNumber(number) => renderer.set_current_line_number(number)?,
125            Command::Search(search) => {
126                renderer.search(&search, /*reverses*/ false);
127                self.last_search = Some(search);
128            }
129            Command::SearchPrev | Command::SearchNext => {
130                if let Some(search) = self.last_search.as_ref() {
131                    renderer.search(search, command == Command::SearchPrev);
132                }
133            }
134            Command::Older => {
135                execute!(
136                    out,
137                    terminal::Clear(terminal::ClearType::All),
138                    cursor::MoveTo(0, 0),
139                    style::Print("Working...")
140                )?;
141                let path_before = renderer.path().to_path_buf();
142                let old_commit_id = renderer.commit_id();
143                renderer
144                    .set_commit_id_to_older_than_current_line()
145                    // Invalidate because the "working" message cleared the screen.
146                    .inspect_err(|_| renderer.invalidate_render())?;
147                self.history.push(old_commit_id);
148                if path_before != renderer.path() {
149                    ui.set_prompt(format!("Path changed to {}", renderer.path().display()));
150                }
151            }
152            Command::Newer => {
153                if let Some(commit_id) = self.history.pop() {
154                    let path_before = renderer.path().to_path_buf();
155                    renderer.set_commit_id(commit_id)?;
156                    if path_before != renderer.path() {
157                        ui.set_prompt(format!("Path changed to {}", renderer.path().display()));
158                    }
159                }
160            }
161            Command::Copy => {
162                if let Ok(commit_id) = renderer.current_line_commit_id() {
163                    execute!(
164                        out,
165                        CopyToClipboard::to_clipboard_from(commit_id.to_string())
166                    )?;
167                    ui.set_prompt("Copied to clipboard".to_string());
168                }
169            }
170            Command::ShowCommit | Command::ShowDiff => {
171                let mut terminal_raw_mode = TerminalRawModeScope::new(false)?;
172                renderer.show_current_line_commit(command == Command::ShowDiff)?;
173                terminal_raw_mode.reset()?;
174                CommandUI::wait_for_any_key("Press any key to continue...")?;
175            }
176            Command::Help => {
177                execute!(
178                    out,
179                    terminal::Clear(terminal::ClearType::All),
180                    cursor::MoveTo(0, 0),
181                )?;
182                renderer.invalidate_render();
183                let mut terminal_raw_mode = TerminalRawModeScope::new(false)?;
184                ui.key_map.print_help();
185                println!();
186                terminal_raw_mode.reset()?;
187                CommandUI::wait_for_any_key("Press any key to continue...")?;
188            }
189            Command::Timeout => renderer.read_poll()?,
190            Command::Repaint => renderer.invalidate_render(),
191            Command::Resize(columns, rows) => renderer.set_view_size((columns, rows - 1)),
192            Command::Debug => {
193                let commit_id = renderer.current_line_commit_id()?;
194                let commit = renderer.history().commits().get_by_commit_id(commit_id)?;
195                debug!("debug_current_line: {commit:?}");
196            }
197            Command::Quit => {}
198        }
199        Ok(())
200    }
201}