1use std::io::{self, stdout};
2use std::path::PathBuf;
3
4use crossterm::{
5 event::{self, Event, KeyCode, KeyEventKind},
6 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
7 ExecutableCommand,
8};
9use ratatui::{
10 prelude::*,
11 widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
12};
13
14#[derive(Clone)]
16pub struct ProposedChange {
17 pub path: PathBuf,
18 pub line_idx: usize,
19 pub old_line: String,
20 pub new_line: String,
21 pub context_before: Vec<String>,
22 pub context_after: Vec<String>,
23 pub selected: bool,
24}
25
26pub fn select_changes(changes: &mut [ProposedChange]) -> io::Result<bool> {
29 if changes.is_empty() {
30 return Ok(true);
31 }
32
33 enable_raw_mode()?;
34 stdout().execute(EnterAlternateScreen)?;
35
36 let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
37 let mut state = ListState::default();
38 state.select(Some(0));
39
40 let result = run_tui(&mut terminal, changes, &mut state);
41
42 disable_raw_mode()?;
43 stdout().execute(LeaveAlternateScreen)?;
44
45 result
46}
47
48fn run_tui(
49 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
50 changes: &mut [ProposedChange],
51 state: &mut ListState,
52) -> io::Result<bool> {
53 loop {
54 terminal.draw(|frame| draw(frame, changes, state))?;
55
56 if let Event::Key(key) = event::read()? {
57 if key.kind != KeyEventKind::Press {
58 continue;
59 }
60
61 match key.code {
62 KeyCode::Char('q') | KeyCode::Esc => return Ok(false),
63 KeyCode::Enter => return Ok(true),
64 KeyCode::Up | KeyCode::Char('k') => {
65 let i = state.selected().unwrap_or(0);
66 let new_i = if i == 0 { changes.len() - 1 } else { i - 1 };
67 state.select(Some(new_i));
68 }
69 KeyCode::Down | KeyCode::Char('j') => {
70 let i = state.selected().unwrap_or(0);
71 let new_i = if i >= changes.len() - 1 { 0 } else { i + 1 };
72 state.select(Some(new_i));
73 }
74 KeyCode::Char(' ') => {
75 if let Some(i) = state.selected() {
76 changes[i].selected = !changes[i].selected;
77 }
78 }
79 KeyCode::Char('a') => {
80 for change in changes.iter_mut() {
82 change.selected = true;
83 }
84 }
85 KeyCode::Char('n') => {
86 for change in changes.iter_mut() {
88 change.selected = false;
89 }
90 }
91 _ => {}
92 }
93 }
94 }
95}
96
97fn draw(frame: &mut Frame, changes: &[ProposedChange], state: &mut ListState) {
98 let chunks = Layout::default()
99 .direction(Direction::Vertical)
100 .constraints([
101 Constraint::Min(5),
102 Constraint::Length(10),
103 Constraint::Length(1),
104 ])
105 .split(frame.area());
106
107 let cwd = std::env::current_dir().unwrap_or_default();
109 let items: Vec<ListItem> = changes
110 .iter()
111 .map(|change| {
112 let checkbox = if change.selected { "[x] " } else { "[ ] " };
113 let rel_path = change.path.strip_prefix(&cwd).unwrap_or(&change.path);
114 let parent = rel_path
115 .parent()
116 .map(|p| p.to_string_lossy())
117 .unwrap_or_default();
118 let filename = rel_path
119 .file_name()
120 .map(|f| f.to_string_lossy())
121 .unwrap_or_default();
122 let line_num = change.line_idx + 1;
123
124 let mut spans = vec![Span::raw(checkbox)];
125 if !parent.is_empty() {
126 spans.push(Span::styled(
127 format!("{}/", parent),
128 Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD),
129 ));
130 }
131 spans.push(Span::styled(
132 filename.to_string(),
133 Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD),
134 ));
135 spans.push(Span::raw(format!(":{}", line_num)));
136
137 ListItem::new(Line::from(spans))
138 })
139 .collect();
140
141 let list = List::new(items)
142 .block(Block::default().borders(Borders::ALL).title(" Changes (space: toggle, a: all, n: none) "))
143 .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
144 .highlight_symbol("> ");
145
146 frame.render_stateful_widget(list, chunks[0], state);
147
148 if let Some(i) = state.selected() {
150 let change = &changes[i];
151 let mut preview_lines: Vec<Line> = Vec::new();
152
153 let start_line = change.line_idx.saturating_sub(change.context_before.len());
154
155 for (offset, line) in change.context_before.iter().enumerate() {
157 let line_num = start_line + offset + 1;
158 preview_lines.push(Line::from(vec![
159 Span::styled(format!(" {:4} │ ", line_num), Style::default().fg(Color::DarkGray)),
160 Span::raw(line),
161 ]));
162 }
163
164 let line_num = change.line_idx + 1;
165 if change.selected {
166 preview_lines.push(Line::from(vec![
168 Span::styled(format!("- {:4} │ ", line_num), Style::default().fg(Color::Red)),
169 Span::styled(&change.old_line, Style::default().fg(Color::Red)),
170 ]));
171 preview_lines.push(Line::from(vec![
172 Span::styled(format!("+ {:4} │ ", line_num), Style::default().fg(Color::Green)),
173 Span::styled(&change.new_line, Style::default().fg(Color::Green)),
174 ]));
175 } else {
176 preview_lines.push(Line::from(vec![
178 Span::styled(format!(" {:4} │ ", line_num), Style::default().fg(Color::DarkGray)),
179 Span::raw(&change.old_line),
180 ]));
181 }
182
183 for (offset, line) in change.context_after.iter().enumerate() {
185 let line_num = change.line_idx + 2 + offset;
186 preview_lines.push(Line::from(vec![
187 Span::styled(format!(" {:4} │ ", line_num), Style::default().fg(Color::DarkGray)),
188 Span::raw(line),
189 ]));
190 }
191
192 let preview = Paragraph::new(preview_lines)
193 .block(Block::default().borders(Borders::ALL).title(" Preview "))
194 .wrap(Wrap { trim: false });
195
196 frame.render_widget(preview, chunks[1]);
197 }
198
199 let help = Paragraph::new(" ↑↓/jk: navigate │ space: toggle │ a: all │ n: none │ enter: apply │ q/esc: cancel ");
201 frame.render_widget(help, chunks[2]);
202}