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