Skip to main content

check_updates/
interactive.rs

1use std::collections::HashSet;
2use std::io::{Write, stdout};
3use std::time::Duration;
4
5use anyhow::Result;
6use crossterm::{
7    cursor::{Hide, MoveTo, MoveToColumn, Show},
8    event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
9    execute,
10    style::{Attribute, Print, SetAttribute},
11    terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
12};
13
14use crate::changes::safe_text;
15use crate::dependencies::version_change;
16use crate::output::Pending;
17
18struct Screen;
19
20impl Screen {
21    fn open() -> Result<Self> {
22        terminal::enable_raw_mode()?;
23        let guard = Self;
24        execute!(stdout(), EnterAlternateScreen, Hide)?;
25        Ok(guard)
26    }
27}
28
29impl Drop for Screen {
30    fn drop(&mut self) {
31        let _ = execute!(
32            stdout(),
33            SetAttribute(Attribute::Reset),
34            Show,
35            LeaveAlternateScreen
36        );
37        let _ = terminal::disable_raw_mode();
38    }
39}
40
41fn read_key() -> Result<KeyEvent> {
42    loop {
43        crate::check_interrupt()?;
44        if event::poll(Duration::from_millis(100))?
45            && let Event::Key(key) = event::read()?
46            && key.kind != KeyEventKind::Release
47        {
48            if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
49                return Err(crate::Interrupted.into());
50            }
51            return Ok(key);
52        }
53    }
54}
55
56#[derive(Default)]
57struct Selector {
58    rows: Vec<(usize, Option<usize>)>,
59    selected: HashSet<(usize, usize)>,
60    cursor: usize,
61}
62
63impl Selector {
64    fn new(pending: &Pending) -> Self {
65        let mut selector = Self::default();
66        for (project, (_, updates)) in pending.iter().enumerate() {
67            selector.rows.push((project, None));
68            selector
69                .rows
70                .extend((0..updates.len()).map(|index| (project, Some(index))));
71        }
72        selector
73    }
74
75    fn toggle(&mut self, pending: &Pending) {
76        let (project, index) = self.rows[self.cursor];
77        if let Some(index) = index {
78            if !self.selected.remove(&(project, index)) {
79                self.selected.insert((project, index));
80            }
81        } else {
82            let children: Vec<_> = (0..pending[project].1.len())
83                .map(|index| (project, index))
84                .collect();
85            let clear = children.iter().all(|child| self.selected.contains(child));
86            for child in children {
87                if clear {
88                    self.selected.remove(&child);
89                } else {
90                    self.selected.insert(child);
91                }
92            }
93        }
94    }
95
96    fn render(&self, pending: &Pending) -> Result<()> {
97        let (width, height) = terminal::size()?;
98        let visible = usize::from(height.saturating_sub(3).max(1));
99        let start = self.cursor.saturating_sub(visible - 1);
100        let mut output = stdout();
101        execute!(
102            output,
103            MoveTo(0, 0),
104            Clear(ClearType::All),
105            Print(format!(
106                "Select dependency updates ({} selected). Labels do not prove compatibility.\r\nArrows: move | Space: toggle entry/project | Enter: review | Esc: cancel | Ctrl+C: interrupt\r\n",
107                self.selected.len()
108            ))
109        )?;
110        for (row, &(project_index, index)) in self.rows.iter().enumerate().skip(start).take(visible)
111        {
112            let (project, updates) = &pending[project_index];
113            let label = if let Some(index) = index {
114                let update = &updates[index];
115                format!(
116                    "  [{}] {}: {} -> {} [{}]",
117                    if self.selected.contains(&(project_index, index)) {
118                        "x"
119                    } else {
120                        " "
121                    },
122                    update.dependency.section.join("."),
123                    update.dependency.requirement,
124                    update.version,
125                    version_change(update)
126                )
127            } else {
128                let selected = (0..updates.len())
129                    .filter(|index| self.selected.contains(&(project_index, *index)))
130                    .count();
131                format!(
132                    "[{selected}/{}] {} (select/clear project)",
133                    updates.len(),
134                    project.path.display()
135                )
136            };
137            // A single screen row per occurrence keeps scrolling and selection aligned.
138            let label: String = safe_text(&label, false)
139                .chars()
140                .take(usize::from(width.saturating_sub(1)))
141                .collect();
142            if row == self.cursor {
143                execute!(output, SetAttribute(Attribute::Reverse))?;
144            }
145            execute!(
146                output,
147                Print(label),
148                SetAttribute(Attribute::Reset),
149                Print("\r\n")
150            )?;
151        }
152        output.flush()?;
153        Ok(())
154    }
155}
156
157pub fn select_updates(pending: &Pending) -> Result<Pending> {
158    let _screen = Screen::open()?;
159    let mut selector = Selector::new(pending);
160    if selector.rows.is_empty() {
161        return Ok(Vec::new());
162    }
163    loop {
164        selector.render(pending)?;
165        match read_key()?.code {
166            KeyCode::Up => {
167                selector.cursor = (selector.cursor + selector.rows.len() - 1) % selector.rows.len()
168            }
169            KeyCode::Down => selector.cursor = (selector.cursor + 1) % selector.rows.len(),
170            KeyCode::Char(' ') => selector.toggle(pending),
171            KeyCode::Esc => return Ok(Vec::new()),
172            KeyCode::Enter => break,
173            _ => (),
174        }
175    }
176    Ok(pending
177        .iter()
178        .enumerate()
179        .filter_map(|(project_index, (project, updates))| {
180            let chosen: Vec<_> = updates
181                .iter()
182                .enumerate()
183                .filter(|(index, _)| selector.selected.contains(&(project_index, *index)))
184                .map(|(_, update)| update.clone())
185                .collect();
186            (!chosen.is_empty()).then(|| (project.clone(), chosen))
187        })
188        .collect())
189}
190
191struct Confirmation;
192
193impl Drop for Confirmation {
194    fn drop(&mut self) {
195        let _ = terminal::disable_raw_mode();
196        let _ = execute!(
197            stdout(),
198            MoveToColumn(0),
199            Clear(ClearType::CurrentLine),
200            Show
201        );
202    }
203}
204
205pub fn confirm_changes() -> Result<bool> {
206    // Keep the diff visible above the confirmation instead of opening another screen.
207    stdout().flush()?;
208    terminal::enable_raw_mode()?;
209    let _guard = Confirmation;
210    let mut accepted = false;
211    loop {
212        execute!(
213            stdout(),
214            MoveToColumn(0),
215            Clear(ClearType::CurrentLine),
216            Print(format!(
217                "Apply these changes? {} (Arrows/Space: choose | Enter: confirm | y/n | Esc: cancel)",
218                if accepted { "No / [Yes]" } else { "[No] / Yes" }
219            ))
220        )?;
221        stdout().flush()?;
222        match read_key()?.code {
223            KeyCode::Char('y' | 'Y') => return Ok(true),
224            KeyCode::Esc | KeyCode::Char('n' | 'N') => return Ok(false),
225            KeyCode::Enter => return Ok(accepted),
226            KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down | KeyCode::Char(' ') => {
227                accepted = !accepted
228            }
229            _ => (),
230        }
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::dependencies::{Dependency, Update};
238    use crate::workspace::Project;
239
240    #[test]
241    fn project_toggle_and_occurrence_toggle_are_independent() {
242        let update = Update {
243            dependency: Dependency {
244                requirement: "demo>=1".into(),
245                section: vec!["project".into(), "dependencies".into()],
246                index: 0,
247            },
248            replacement: "demo>=2".into(),
249            version: "2".parse().unwrap(),
250            release_link: None,
251            project_link: None,
252        };
253        let pending = vec![(
254            Project {
255                path: "pyproject.toml".into(),
256                document: Default::default(),
257                original: Vec::new(),
258            },
259            vec![update.clone(), update],
260        )];
261        let mut selector = Selector::new(&pending);
262        assert!(selector.selected.is_empty());
263        selector.toggle(&pending);
264        assert_eq!(selector.selected.len(), 2);
265        selector.cursor = 1;
266        selector.toggle(&pending);
267        assert_eq!(selector.selected.len(), 1);
268        assert!(selector.selected.contains(&(0, 1)));
269        selector.cursor = 0;
270        selector.toggle(&pending);
271        assert_eq!(selector.selected.len(), 2);
272        selector.toggle(&pending);
273        assert!(selector.selected.is_empty());
274    }
275}