Skip to main content

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