Skip to main content

_bver/
tui.rs

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/// A proposed change to a file
15#[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
26/// Run the TUI to select which changes to apply
27/// Returns the indices of selected changes
28pub 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                    // Select all
81                    for change in changes.iter_mut() {
82                        change.selected = true;
83                    }
84                }
85                KeyCode::Char('n') => {
86                    // Deselect all
87                    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    // Changes list
108    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    // Preview pane
149    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        // Context before
156        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            // Show diff: old line (red) and new line (green)
167            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            // No change: show original line normally
177            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        // Context after
184        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    // Help line
200    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}