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#[derive(Debug, Default, Parser)]
20#[command(version, about)]
21struct Args {
22 #[cfg(feature = "git2")]
24 #[arg(long, default_value_t = false)]
25 git: bool,
26
27 #[cfg(not(feature = "git2"))]
29 #[arg(long, default_value_t = false)]
30 git2: bool,
31
32 path: PathBuf,
34}
35
36#[derive(Debug, Default)]
37pub 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 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::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, 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}