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, 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_with_alternate_screen()?;
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                let path_before = renderer.path().to_path_buf();
136                let old_commit_id = renderer.commit_id();
137                renderer.set_commit_id_to_older_than_current_line()?;
138                if !old_commit_id.is_zero() {
139                    self.history.push(old_commit_id);
140                }
141                if path_before != renderer.path() {
142                    ui.set_prompt(format!("Path changed to {}", renderer.path().display()));
143                }
144            }
145            Command::Newer => {
146                if let Some(commit_id) = self.history.pop() {
147                    let path_before = renderer.path().to_path_buf();
148                    renderer.set_commit_id(commit_id)?;
149                    if path_before != renderer.path() {
150                        ui.set_prompt(format!("Path changed to {}", renderer.path().display()));
151                    }
152                }
153            }
154            Command::Log => {
155                let old_commit_id = renderer.commit_id();
156                renderer.set_log_content()?;
157                if !old_commit_id.is_zero() {
158                    self.history.push(old_commit_id);
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}