1use super::*;
6
7use crossterm;
8
9use crossterm::{
10 cursor,
11 event::{Event, KeyCode, KeyEvent, KeyModifiers},
12 style::{Attribute, Color, ContentStyle, PrintStyledContent, StyledContent},
13 terminal, QueueableCommand,
14};
15
16use std::io::stdout;
17
18const HELP: &str = r##"y - apply this suggestion
19n - do not apply the suggested correction
20q - quit; do not stage this hunk or any of the remaining ones
21d - do not apply this suggestion and skip the rest of the file
22g - select a suggestion to go to
23j - leave this hunk undecided, see next undecided hunk
24J - leave this hunk undecided, see next hunk
25e - manually edit the current hunk
26? - print help
27
28
29
30
31"##;
32
33pub struct ScopedRaw;
35
36impl ScopedRaw {
37 fn new() -> Result<Self> {
42 crossterm::terminal::enable_raw_mode()?;
43 Ok(Self)
44 }
45
46 pub fn restore_terminal() -> Result<()> {
50 crossterm::terminal::disable_raw_mode()?;
51 stdout()
52 .queue(crossterm::cursor::Show)?
53 .flush()
54 .wrap_err_with(|| eyre!("Failed to restore terminal"))
55 }
56}
57
58impl Drop for ScopedRaw {
59 fn drop(&mut self) {
60 let _ = Self::restore_terminal();
61 }
62}
63
64#[derive(Debug, Clone, Copy)]
66enum Direction {
67 Forward,
69 #[allow(unused)]
71 Backward,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub(super) enum UserSelection {
77 Replacement(BandAid),
79 Skip,
81 Previous,
83 Help,
85 SkipFile,
87 Nop,
89 Abort,
91 Quit,
93}
94
95#[derive(Debug)]
97struct State<'s, 't>
98where
99 't: 's,
100{
101 pub suggestion: &'s Suggestion<'t>,
103 pub custom_replacement: String,
105 pub cursor_offset: u16,
106 pub backticked_original: String,
108 pub pick_idx: usize,
110 pub n_items: usize,
112}
113
114impl<'s, 't> From<&'s Suggestion<'t>> for State<'s, 't> {
115 fn from(suggestion: &'s Suggestion<'t>) -> Self {
116 Self {
117 suggestion,
118 custom_replacement: String::new(),
119 cursor_offset: 0,
120 backticked_original: format!(
126 "`{}`",
127 sub_chars(suggestion.chunk.as_str(), suggestion.range.clone())
128 ),
129 pick_idx: 1_usize + usize::from(!suggestion.replacements.is_empty()),
131 n_items: suggestion.replacements.len() + 2,
133 }
134 }
135}
136
137impl<'s, 't> State<'s, 't>
138where
139 't: 's,
140{
141 pub fn select_next(&mut self) {
143 self.pick_idx = (self.pick_idx + 1).rem_euclid(self.n_items);
144 }
145
146 pub fn select_previous(&mut self) {
148 self.pick_idx = (self.pick_idx + self.n_items - 1).rem_euclid(self.n_items);
149 }
150
151 pub fn select_custom(&mut self) {
153 self.pick_idx = 0;
154 }
155
156 pub fn is_custom_entry(&self) -> bool {
158 self.pick_idx == 0
159 }
160
161 pub fn is_ticked_entry(&self) -> bool {
162 self.pick_idx == 1
163 }
164
165 pub fn to_bandaid(&self) -> BandAid {
167 if self.is_ticked_entry() {
168 BandAid::from((self.backticked_original.clone(), &self.suggestion.span))
169 } else if self.is_custom_entry() {
170 BandAid::from((self.custom_replacement.clone(), &self.suggestion.span))
171 } else {
172 let replacement = self
173 .suggestion
174 .replacements
175 .get(self.pick_idx.saturating_sub(2)) .expect("User Pick index is never out of bounds. qed");
177 BandAid::from((replacement.to_owned(), &self.suggestion.span))
178 }
179 }
180}
181
182#[derive(Debug, Clone, Default)]
184pub struct UserPicked {
185 pub bandaids: indexmap::IndexMap<ContentOrigin, Vec<BandAid>>,
187}
188
189impl UserPicked {
190 pub fn total_count(&self) -> usize {
192 self.bandaids
193 .iter()
194 .map(|(_origin, bandaids)| bandaids.len())
195 .sum()
196 }
197
198 pub fn is_empty(&self) -> bool {
200 !self
201 .bandaids
202 .iter()
203 .any(|(_origin, bandaids)| !bandaids.is_empty())
204 }
205
206 pub fn add_bandaid(&mut self, origin: &ContentOrigin, bandaid: BandAid) {
208 self.bandaids
209 .entry(origin.clone())
210 .or_insert_with(|| Vec::with_capacity(10))
211 .push(bandaid);
212 }
213
214 pub fn add_bandaids<I>(&mut self, origin: &ContentOrigin, fixes: I)
216 where
217 I: IntoIterator<Item = BandAid>,
218 {
219 let iter = fixes.into_iter();
220 self.bandaids
221 .entry(origin.clone())
222 .or_insert_with(|| Vec::with_capacity(iter.size_hint().0))
223 .extend(iter);
224 }
225
226 pub fn extend(&mut self, other: Self) {
228 self.bandaids.extend(other.bandaids);
229 }
230
231 fn enter_custom_replacement(
233 &self,
234 state: &mut State,
235 event: KeyEvent,
236 ) -> Result<UserSelection> {
237 let KeyEvent {
238 code, modifiers, ..
239 } = event;
240
241 let length = state.custom_replacement.len() as u16;
242 match code {
243 KeyCode::Left => state.cursor_offset = state.cursor_offset.saturating_sub(1),
244 KeyCode::Right => state.cursor_offset = (state.cursor_offset + 1).min(length),
245 KeyCode::Up => {
246 state.cursor_offset = length;
247 state.select_next();
248 }
249 KeyCode::Down => {
250 state.cursor_offset = length;
251 state.select_previous();
252 }
253 KeyCode::Backspace => {
254 if state.cursor_offset > 0 {
255 state.cursor_offset -= 1;
256 state
257 .custom_replacement
258 .remove(state.cursor_offset as usize);
259 }
260 }
261 KeyCode::Enter => {
262 let bandaid = state.to_bandaid();
263 return Ok(UserSelection::Replacement(bandaid));
264 }
265 KeyCode::Esc => return Ok(UserSelection::Abort),
266 KeyCode::Char('c') if modifiers == KeyModifiers::CONTROL => {
267 return Ok(UserSelection::Abort);
268 }
269 KeyCode::Char(c) => {
270 state
271 .custom_replacement
272 .insert(state.cursor_offset as usize, c);
273 state.cursor_offset += 1;
274 }
275 _ => {}
276 }
277
278 Ok(UserSelection::Nop)
279 }
280
281 fn print_replacements_list(&self, state: &mut State) -> Result<()> {
289 let mut stdout = stdout();
290
291 let mut tick = ContentStyle::new();
292 tick.foreground_color = Some(Color::Green);
293 tick.attributes = Attribute::Bold.into();
294
295 let mut highlight = ContentStyle::new();
296 highlight.background_color = Some(Color::Black);
297 highlight.foreground_color = Some(Color::Green);
298 highlight.attributes = Attribute::Bold.into();
299
300 let mut others = ContentStyle::new();
301 others.background_color = Some(Color::Black);
302 others.foreground_color = Some(Color::Blue);
303
304 let mut custom = ContentStyle::new();
305 custom.background_color = Some(Color::Black);
306 custom.foreground_color = Some(Color::Yellow);
307
308 stdout.queue(cursor::SavePosition)?;
311 let _ = stdout.queue(cursor::MoveDown(1))?;
312
313 let active_idx = state.pick_idx;
314
315 let custom_content = if state.custom_replacement.is_empty() {
316 "..."
317 } else {
318 state.custom_replacement.as_str()
319 };
320
321 std::iter::once((&custom, custom_content))
322 .chain(std::iter::once((
323 &others,
324 state.backticked_original.as_str(),
325 )))
326 .chain(
327 state
328 .suggestion
329 .replacements
330 .iter()
331 .map(|s| (&others, s.as_str())),
332 )
333 .enumerate()
334 .map(|(idx, (style, content))| {
335 (idx, PrintStyledContent(StyledContent::new(*style, content)))
336 })
337 .try_fold(&mut stdout, |cmd, (idx, mut item)| {
338 let cmd = cmd
339 .queue(cursor::MoveUp(1))?
340 .queue(terminal::Clear(terminal::ClearType::CurrentLine))?;
341
342 if idx == active_idx {
343 *item.0.style_mut() = highlight;
344 if idx == 0 {
345 cmd.queue(crossterm::cursor::Show)?;
346 } else {
347 cmd.queue(crossterm::cursor::Hide)?;
348 }
349 cmd.queue(cursor::MoveToColumn(2))?
350 .queue(PrintStyledContent(StyledContent::new(tick, 'ยป')))?
351 .queue(cursor::MoveToColumn(4))?
352 } else {
353 cmd.queue(cursor::MoveToColumn(4))?
354 }
355 .queue(item)
356 })?;
357
358 stdout.queue(cursor::RestorePosition)?.flush()?;
359
360 Ok(())
361 }
362
363 fn user_input(
365 &self,
366 state: &mut State,
367 running_idx: usize,
368 total: usize,
369 ) -> Result<UserSelection> {
370 let skip = {
371 let _guard = ScopedRaw::new();
372
373 let mut boring = ContentStyle::new();
374 boring.foreground_color = Some(Color::Blue);
375 boring.attributes = Attribute::Bold.into();
376
377 let question = format!(
378 "({nth}/{of_n}) Apply this suggestion [y,n,q,a,d,j,e,?]?",
379 nth = running_idx + 1,
380 of_n = total
381 );
382
383 #[allow(clippy::items_after_statements)]
390 const ERASE: u16 = 4;
391 const QUESTION: u16 = 3;
393 let extra_rows_to_flush =
394 (state.n_items.saturating_sub((ERASE - QUESTION) as usize)) as u16;
395 stdout()
396 .queue(cursor::Hide)?
397 .queue(cursor::MoveUp(ERASE))? .queue(terminal::Clear(terminal::ClearType::FromCursorDown))?
399 .queue(cursor::MoveDown(1))? .queue(PrintStyledContent(StyledContent::new(boring, question)))?
401 .queue(terminal::ScrollUp(extra_rows_to_flush))?
402 .queue(cursor::MoveToColumn(0))?
403 .queue(cursor::MoveDown(extra_rows_to_flush))?;
404 ERASE - QUESTION
405 };
406
407 loop {
408 let mut _guard = ScopedRaw::new();
409
410 self.print_replacements_list(state)?;
411
412 if state.is_custom_entry() {
413 stdout().queue(cursor::SavePosition)?;
414 stdout()
415 .queue(cursor::Show)?
416 .queue(cursor::MoveToPreviousLine(1 - skip))?
417 .queue(cursor::MoveToColumn(4 + state.cursor_offset))?;
418 stdout().flush()?;
419 }
420
421 let event = match crossterm::event::read()
422 .wrap_err_with(|| eyre!("Something unexpected happened on the CLI"))?
423 {
424 Event::Key(event) => event,
425 Event::Resize(..) => {
426 drop(_guard);
427 continue;
428 }
429 sth => {
430 log::trace!("read() something other than a key: {sth:?}");
431 break;
432 }
433 };
434
435 if state.is_custom_entry() {
436 drop(_guard);
437 log::info!("Custom entry mode");
438 _guard = ScopedRaw::new();
439
440 let pick = self.enter_custom_replacement(state, event)?;
441
442 stdout()
443 .queue(cursor::Hide)?
444 .queue(cursor::RestorePosition)?;
445
446 match pick {
447 UserSelection::Nop => continue,
448 other => return Ok(other),
449 }
450 }
451
452 drop(_guard);
453 log::trace!("registered event: {event:?}");
455
456 let KeyEvent {
457 code, modifiers, ..
458 } = event;
459
460 match code {
461 KeyCode::Up => state.select_next(),
462 KeyCode::Down => state.select_previous(),
463 KeyCode::Enter | KeyCode::Char('y') => {
464 let bandaid = state.to_bandaid();
465 return Ok(UserSelection::Replacement(bandaid));
467 }
468 KeyCode::Char('n') => return Ok(UserSelection::Skip),
469 KeyCode::Char('j') => return Ok(UserSelection::Previous),
470 KeyCode::Char('q') | KeyCode::Esc => return Ok(UserSelection::Quit),
471 KeyCode::Char('c') if modifiers == KeyModifiers::CONTROL => {
472 return Ok(UserSelection::Abort)
473 }
474 KeyCode::Char('d') => return Ok(UserSelection::SkipFile),
475 KeyCode::Char('e') => {
476 state.select_custom();
478 }
479 KeyCode::Char('?') => return Ok(UserSelection::Help),
480 x => {
481 log::trace!("Unexpected input {x:?}");
482 }
483 }
484 }
485 unreachable!("Unexpected return when dealing with user input")
486 }
487
488 pub(super) fn select_interactive(
489 origin: ContentOrigin,
490 suggestions: Vec<Suggestion<'_>>,
491 ) -> Result<(Self, UserSelection)> {
492 let count = suggestions.len();
493 let mut picked = UserPicked::default();
494
495 let mut suggestions_it = suggestions.iter().enumerate();
496 let start = suggestions_it.clone();
497
498 let direction = Direction::Forward;
500 'outer: loop {
501 let opt_next = match direction {
502 Direction::Forward => suggestions_it.next(),
503 Direction::Backward => suggestions_it.next_back(),
505 };
506
507 log::trace!("next() ---> {opt_next:?}");
508
509 let (idx, suggestion) = match opt_next {
510 Some(x) => x,
511 None => match direction {
512 Direction::Forward => {
513 log::trace!("completed file, continue to next");
514 break; }
516 Direction::Backward => {
517 log::trace!("went back, now back at the beginning");
518 suggestions_it = start.clone();
519 continue;
520 } },
522 };
523 if suggestion.replacements.is_empty() {
524 log::trace!("BUG: Suggestion did not contain a replacement, skip");
525 continue;
526 }
527 println!("{suggestion}");
528
529 let mut state = State::from(suggestion);
530
531 'inner: loop {
532 match picked.user_input(&mut state, idx, count)? {
533 usel @ (UserSelection::Abort | UserSelection::Quit) => {
534 let _ = ScopedRaw::restore_terminal();
535 return Ok((picked, usel));
536 }
537 UserSelection::SkipFile => break 'outer,
538 UserSelection::Previous => {
539 log::warn!("Requires a iterator which works bidrectionally");
540 continue 'inner;
541 }
542 UserSelection::Help => {
543 println!("{HELP}");
544 continue 'inner;
545 }
546 UserSelection::Replacement(bandaid) => {
547 picked.add_bandaid(&origin, bandaid);
548 }
549 UserSelection::Nop | UserSelection::Skip => {}
550 };
551 break 'inner;
552 }
553 }
554 Ok((picked, UserSelection::Nop))
555 }
556}