Skip to main content

rhc/
interactive.rs

1use crate::choice::Choice;
2use crate::config::Config;
3use crate::environment::Environment;
4use crate::files;
5use crate::keyvalue::KeyValue;
6use crate::{colors::Colors, request_definition::RequestDefinition};
7use std::fs::OpenOptions;
8use std::io::{self, Write};
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, RwLock};
11use sublime_fuzzy::best_match;
12use termion::cursor::{Goto, Hide, Show};
13use termion::event::Key;
14use termion::input::Keys;
15use tui::style::{Modifier, Style};
16use tui::widgets::{List, ListState, Paragraph, Text};
17use tui::Terminal;
18use unicode_width::UnicodeWidthStr;
19
20/// Like readline Ctrl-W
21pub fn cut_to_current_word_start(s: &mut String) {
22    let mut cut_a_letter = false;
23    while !s.is_empty() {
24        let popped = s.pop();
25        if let Some(' ') = popped {
26            if cut_a_letter {
27                s.push(' ');
28                break;
29            }
30        } else {
31            cut_a_letter = true;
32        }
33    }
34}
35
36struct InteractiveState {
37    /// What the user has entered into the input buffer
38    query: String,
39
40    /// Holds which item is selected
41    list_state: ListState,
42
43    // When exiting the UI loop, if this is Some, that Choice
44    // will have its request sent.
45    primed: Option<PathBuf>,
46
47    active_env_index: Option<usize>,
48}
49
50impl InteractiveState {
51    fn new() -> InteractiveState {
52        InteractiveState {
53            query: String::new(),
54            list_state: ListState::default(),
55            primed: None,
56            active_env_index: None,
57        }
58    }
59}
60
61pub struct SelectedValues {
62    pub def: RequestDefinition,
63    pub env: Option<Environment>,
64}
65
66pub fn interactive_mode<R: std::io::Read, B: tui::backend::Backend + std::io::Write>(
67    config: &Config,
68    env_path: Option<&Path>,
69    stdin: &mut Keys<R>,
70    terminal: &mut Terminal<B>,
71) -> anyhow::Result<Option<SelectedValues>> {
72    // This Vec<Choice> serves as the source-of-truth that will be filtered on and eventually
73    // selected from. Initially only Paths are populated in the Choice structs, and the associated
74    // RequestDefinition is not present.  The main UI loop accesses it through the read mode of the
75    // RwLock and only uses it to display the List widget. Another thread is spawned to parse each
76    // path and update the Choice's request_definition field via the write mode of the RwLock.
77    let all_choices = Arc::new(RwLock::new(files::list_all_choices(config)));
78
79    let mut app_state = InteractiveState::new();
80
81    let num_choices = all_choices.read().unwrap().len();
82    if num_choices > 0 {
83        app_state.list_state.select(Some(0));
84    }
85
86    let highlight_symbol = ">> ";
87
88    let write_access = Arc::clone(&all_choices);
89    std::thread::spawn(move || {
90        for i in 0..num_choices {
91            let mut writer = write_access.write().unwrap();
92
93            // Try to load the RequestDefinition, and put the Result, whether Ok or Err, into the
94            // Choice
95            let request_definition: anyhow::Result<RequestDefinition> = files::load_file(
96                &writer[i].path,
97                RequestDefinition::new,
98                "request definition",
99            );
100            writer[i].request_definition = Some(request_definition);
101        }
102    });
103
104    let colors = Colors::from(&config.colors);
105    let mut default_style = Style::default();
106    if let Some(default_fg) = colors.default_fg {
107        default_style = default_style.fg(default_fg);
108    }
109    if let Some(default_bg) = colors.default_bg {
110        default_style = default_style.bg(default_bg);
111    }
112
113    let mut selected_style = Style::default()
114        .fg(colors.selected_fg)
115        .modifier(Modifier::BOLD);
116    if let Some(selected_bg) = colors.selected_bg {
117        selected_style = selected_style.bg(selected_bg);
118    }
119
120    let mut prompt_style = Style::default().fg(colors.prompt_fg);
121    if let Some(prompt_bg) = colors.prompt_bg {
122        prompt_style = prompt_style.bg(prompt_bg);
123    }
124
125    // Load all the environments available
126    let mut environments: Vec<(Environment, PathBuf)> = files::list_all_environments(&config);
127
128    // If the user started with the --environment flag, find the matching environment, if there is
129    // one, and set that as the selected environment.
130    if let Some(env_path) = env_path {
131        for (i, (_, path)) in environments.iter().enumerate() {
132            if path == env_path {
133                app_state.active_env_index = Some(i);
134            }
135        }
136    }
137
138    loop {
139        // Needed to prevent cursor flicker when navigating the list
140        io::stdout().flush().ok();
141
142        // Inside this loop we only need read access to the Vec<Choice>
143        let inner_guard = Arc::clone(&all_choices);
144        let inner_guard = inner_guard.read().unwrap();
145
146        // Look up the active environment to use it in the prompt
147        let active_env = app_state
148            .active_env_index
149            .map(|i| environments.get(i).unwrap());
150        let active_vars = active_env.map(|(e, _)| &e.variables);
151        let prompt = match active_env {
152            Some((env, _)) => format!("{} > ", env.name),
153            None => "> ".to_string(),
154        };
155
156        // Use fuzzy matching on the Choices' path, and URL/description if present
157        let filtered_choices: Vec<&Choice> = if app_state.query.is_empty() {
158            inner_guard.iter().collect()
159        } else {
160            let mut matching_choices: Vec<(isize, &Choice)> = inner_guard
161                .iter()
162                .filter_map(|choice| {
163                    let target = format!(
164                        "{}{}{}",
165                        &choice.trimmed_path(),
166                        choice.url_or_blank(active_vars),
167                        choice.description_or_blank(),
168                    );
169                    best_match(&app_state.query, &target).map(|result| (result.score(), choice))
170                })
171                .collect();
172
173            // We want to sort descending so the Choice with the highest score is as position 0
174            matching_choices.sort_unstable_by(|(score1, _), (score2, _)| score2.cmp(score1));
175
176            matching_choices.iter().map(|(_, choice)| *choice).collect()
177        };
178
179        if filtered_choices.is_empty() {
180            // Nothing to select
181            app_state.list_state.select(None);
182        } else if app_state.list_state.selected().is_none() {
183            // Went from nothing selected (everything filtered out) to having results, so select
184            // the result with the best score.
185            app_state.list_state.select(Some(0));
186        } else if let Some(selected) = app_state.list_state.selected() {
187            // Since the filtered list could have changed, prevent the selection from going past
188            // the end of the list, which could happen if the user navigates up the list and then
189            // changes the search query.
190            if selected >= filtered_choices.len() {
191                app_state
192                    .list_state
193                    .select(Some(filtered_choices.len() - 1));
194            }
195        }
196
197        terminal.draw(|mut f| {
198            let width = f.size().width;
199            let height = f.size().height;
200
201            // The maximum number of items we can display is limited by the height of the terminal
202            let list_rows = std::cmp::min(
203                filtered_choices.len() as u16,
204                height.checked_sub(1).unwrap_or(0),
205            );
206            let items = filtered_choices
207                .iter()
208                // Have to make room for the highlight symbol, and a 1-column margin on the right
209                .map(|choice| choice.to_text_widget(active_vars));
210            let list = List::new(items)
211                .style(default_style)
212                .start_corner(tui::layout::Corner::BottomLeft)
213                .highlight_style(selected_style)
214                .highlight_symbol(highlight_symbol);
215
216            // The list of choices takes up the whole terminal except for the very bottom row
217            let list_rect = tui::layout::Rect::new(0, height - list_rows - 1, width, list_rows);
218
219            f.render_stateful_widget(list, list_rect, &mut app_state.list_state);
220
221            // The bottom row is used for the query input
222            let query_rect = tui::layout::Rect::new(0, height - 1, width, 1);
223            let query_text = [
224                Text::Styled((&prompt).into(), prompt_style),
225                Text::raw(&app_state.query),
226            ];
227            let input = Paragraph::new(query_text.iter());
228
229            f.render_widget(input, query_rect);
230        })?;
231
232        let height = terminal.size()?.height;
233
234        // Place the cursor at the end of the query input
235        write!(
236            terminal.backend_mut(),
237            "{}",
238            Goto(
239                prompt.width() as u16 + app_state.query.width() as u16 + 1,
240                height
241            )
242        )?;
243
244        let input = stdin.next();
245
246        if let Some(Ok(key)) = input {
247            match key {
248                Key::Ctrl('c') => break,
249                Key::Ctrl('w') => cut_to_current_word_start(&mut app_state.query),
250                Key::Ctrl('u') => {
251                    app_state.query.clear();
252                }
253                Key::Ctrl('p') | Key::Up => {
254                    // Navigate up (increase selection index)
255                    if let Some(selected) = app_state.list_state.selected() {
256                        if selected < filtered_choices.len() - 1 {
257                            app_state.list_state.select(Some(selected + 1));
258                        }
259                    }
260                }
261                Key::Ctrl('n') | Key::Down => {
262                    // Navigate down (decrease selection index)
263                    if let Some(selected) = app_state.list_state.selected() {
264                        if selected > 0 {
265                            app_state.list_state.select(Some(selected - 1));
266                        }
267                    }
268                }
269                Key::Char('\n') => {
270                    // Only prime and break from the loop if something is actually selected
271                    if let Some(i) = app_state.list_state.selected() {
272                        app_state.primed = filtered_choices.get(i).map(|c| c.path.clone());
273                        break;
274                    }
275                }
276                Key::Backspace => {
277                    app_state.query.pop();
278                }
279                Key::Char('\t') => {
280                    // Select next environment
281                    match app_state.active_env_index {
282                        None => {
283                            if !environments.is_empty() {
284                                app_state.active_env_index = Some(0);
285                            }
286                        }
287                        Some(i) => {
288                            if i < environments.len() - 1 {
289                                app_state.active_env_index = Some(i + 1);
290                            } else {
291                                app_state.active_env_index = None;
292                            }
293                        }
294                    }
295                }
296                Key::BackTab => {
297                    // Select previous environment
298                    match app_state.active_env_index {
299                        None => {
300                            if !environments.is_empty() {
301                                app_state.active_env_index = Some(environments.len() - 1);
302                            }
303                        }
304                        Some(i) => {
305                            if i > 0 {
306                                app_state.active_env_index = Some(i - 1);
307                            } else {
308                                app_state.active_env_index = None;
309                            }
310                        }
311                    }
312                }
313                Key::Char(c) => app_state.query.push(c),
314                _ => {}
315            }
316        }
317    }
318
319    let result = match app_state.primed {
320        None => None,
321        Some(path) => {
322            let def: RequestDefinition =
323                files::load_file(&path, RequestDefinition::new, "request definition")?;
324            let env: Option<Environment> = app_state
325                .active_env_index
326                .map(|i| environments.remove(i))
327                .map(|(e, _)| e);
328            Some(SelectedValues { def, env })
329        }
330    };
331
332    Ok(result)
333}
334
335struct PromptState {
336    query: String,
337    list_state: ListState,
338
339    // Which item in the history list is currently selected. If None, this means that either there
340    // are no filtered options to be selected, or the history pane is not active, meaning the user
341    // is in "query input" move.
342    active_history_item_index: Option<usize>,
343}
344
345impl PromptState {
346    fn new() -> PromptState {
347        PromptState {
348            query: String::new(),
349            list_state: ListState::default(),
350            active_history_item_index: None,
351        }
352    }
353}
354
355#[derive(Eq, PartialEq, Debug)]
356struct HistoryItem {
357    name: String,
358    value: String,
359    env_name: String,
360}
361
362/// Given a list of unbound variable names, prompt the user to interactively enter values to bind
363/// them to, and return those created KeyValues. Returning None means the user aborted with Ctrl-C
364/// and we should not send the request.
365pub fn prompt_for_variables<R: std::io::Read, B: tui::backend::Backend + std::io::Write>(
366    config: &Config,
367    names: Vec<&str>,
368    env_name: &str,
369    stdin: &mut Keys<R>,
370    terminal: &mut Terminal<B>,
371) -> anyhow::Result<Option<Vec<KeyValue>>> {
372    // This will ensure that the cursor is restored even if this function panics, the user presses
373    // Ctrl+C, etc
374    let mut terminal = scopeguard::guard(terminal, |t| {
375        write!(t.backend_mut(), "{}", Show).unwrap();
376    });
377
378    let mut state = PromptState::new();
379    let mut result: Vec<KeyValue> = Vec::new();
380
381    let colors = Colors::from(&config.colors);
382    let mut default_style = Style::default();
383    if let Some(default_fg) = colors.default_fg {
384        default_style = default_style.fg(default_fg);
385    }
386    if let Some(default_bg) = colors.default_bg {
387        default_style = default_style.bg(default_bg);
388    }
389
390    let mut selected_style = Style::default()
391        .fg(colors.selected_fg)
392        .modifier(Modifier::BOLD);
393    if let Some(selected_bg) = colors.selected_bg {
394        selected_style = selected_style.bg(selected_bg);
395    }
396
397    let mut prompt_style = Style::default().fg(colors.prompt_fg);
398    if let Some(prompt_bg) = colors.prompt_bg {
399        prompt_style = prompt_style.bg(prompt_bg);
400    }
401
402    let mut variable_style = Style::default().fg(colors.variable_fg);
403    if let Some(variable_bg) = colors.variable_bg {
404        variable_style = variable_style.bg(variable_bg);
405    }
406
407    // Which item in the `names` vector we are currently prompting for
408    let mut current_name_index = 0;
409
410    let prompt = "> ";
411
412    let history_location = shellexpand::tilde(&config.history_file);
413    let history_file = OpenOptions::new()
414        .append(true)
415        .read(true)
416        .create(true)
417        .open(history_location.as_ref())?;
418
419    // Clone the file handle since we need to read from it here, and append to it in the loop
420    let mut history_reader = csv::ReaderBuilder::new()
421        .has_headers(false)
422        .from_reader(history_file.try_clone()?);
423    let mut history_writer = csv::Writer::from_writer(history_file);
424
425    let full_history: Vec<HistoryItem> = history_reader
426        .records()
427        .filter_map(|record| {
428            if let Ok(record) = record {
429                // let split: Vec<&str> = l.split("|||").collect();
430                let split: Vec<&str> = record.iter().collect();
431                if let [name, value, env_name] = split.as_slice() {
432                    Some(HistoryItem {
433                        name: (*name).to_string(),
434                        value: (*value).to_string(),
435                        env_name: (*env_name).to_string(),
436                    })
437                } else {
438                    None
439                }
440            } else {
441                None
442            }
443        })
444        .collect();
445
446    // The new HistoryItems, which don't already appear in full_history, that the user creates
447    // interactively
448    let mut created_items: Vec<HistoryItem> = vec![];
449
450    let highlight_symbol = ">> ";
451
452    loop {
453        io::stdout().flush().ok();
454
455        // First, filter to just the history items that were used for this variable name and
456        // environment
457        let mut filtered_history_items: Vec<&HistoryItem> = full_history
458            .iter()
459            .filter(|item| item.name == names[current_name_index] && item.env_name == env_name)
460            .collect();
461
462        // Fuzzy matching is basically the same as for choosing a request definition
463        if !state.query.is_empty() {
464            let mut matching_items: Vec<(isize, &HistoryItem)> = filtered_history_items
465                .iter()
466                .filter_map(|item| {
467                    let result =
468                        best_match(&state.query, &item.value).map(|result| (result.score(), *item));
469                    result
470                })
471                .collect();
472
473            matching_items.sort_unstable_by(|(score1, _), (score2, _)| score2.cmp(score1));
474
475            filtered_history_items = matching_items.iter().map(|(_, item)| *item).collect();
476        };
477
478        state.list_state.select(state.active_history_item_index);
479
480        let in_history_mode = state.active_history_item_index.is_some();
481        let matching_history_items = filtered_history_items.iter().map(|item| {
482            if in_history_mode {
483                Text::raw(item.value.to_string())
484            } else {
485                Text::raw(format!("   {}", item.value))
486            }
487        });
488
489        let list = List::new(matching_history_items)
490            .start_corner(tui::layout::Corner::BottomLeft)
491            .style(default_style)
492            .highlight_style(selected_style)
493            .highlight_symbol(highlight_symbol);
494
495        let explanation_text = [
496            Text::raw("Enter a value for "),
497            Text::styled(names[current_name_index], variable_style),
498        ];
499        let explanation_widget = Paragraph::new(explanation_text.iter());
500
501        terminal.draw(|mut f| {
502            let width = f.size().width;
503            let height = f.size().height;
504
505            // Similar to selecting a request definition, the number of items we can display in the
506            // vertical list is limited by the terminal's height. We also need to reserve 2 rows
507            // for the explanation and query rows. Be careful not to run into overflow, as these
508            // are unsigned integers.
509            let list_rows = std::cmp::min(
510                filtered_history_items.len() as u16,
511                height.checked_sub(2).unwrap_or(0),
512            );
513
514            // History selection box is all of the screen except the bottom 2 rows
515            let history_rect = tui::layout::Rect::new(0, height - list_rows - 2, width, list_rows);
516            f.render_stateful_widget(list, history_rect, &mut state.list_state);
517
518            // After that is the prompt/explanation row
519            let explanation_rect = tui::layout::Rect::new(0, height - 2, width, 1);
520            f.render_widget(explanation_widget, explanation_rect);
521
522            // The bottom row is for input
523            let query_rect = tui::layout::Rect::new(0, height - 1, width, 1);
524            let query_text = [
525                Text::Styled(prompt.into(), prompt_style),
526                Text::raw(&state.query),
527            ];
528
529            let query_widget = Paragraph::new(query_text.iter());
530            f.render_widget(query_widget, query_rect);
531        })?;
532
533        let height = terminal.size()?.height;
534
535        if !in_history_mode {
536            write!(terminal.backend_mut(), "{}", Show)?;
537            write!(
538                terminal.backend_mut(),
539                "{}",
540                Goto(
541                    prompt.width() as u16 + state.query.width() as u16 + 1,
542                    height
543                )
544            )?;
545        }
546
547        let input = stdin.next();
548        if let Some(Ok(key)) = input {
549            match key {
550                Key::Ctrl('c') => break,
551                Key::Ctrl('w') => {
552                    if !in_history_mode {
553                        cut_to_current_word_start(&mut state.query)
554                    }
555                }
556                Key::Ctrl('u') => {
557                    if !in_history_mode {
558                        state.query.clear();
559                    }
560                }
561                Key::Char('\t') | Key::BackTab => {
562                    if in_history_mode {
563                        state.active_history_item_index = None;
564                    } else {
565                        // Can only move to "history selection" mode if there is actually something
566                        // to select
567                        if !filtered_history_items.is_empty() {
568                            state.active_history_item_index = Some(0);
569                            write!(terminal.backend_mut(), "{}", Hide)?;
570                        }
571                    }
572                }
573                Key::Ctrl('p') | Key::Up => {
574                    if let Some(i) = state.active_history_item_index {
575                        if i < filtered_history_items.len() - 1 {
576                            state.active_history_item_index = Some(i + 1);
577                        }
578                    }
579                }
580                Key::Ctrl('n') | Key::Down => {
581                    if let Some(i) = state.active_history_item_index {
582                        if i > 0 {
583                            state.active_history_item_index = Some(i - 1);
584                        }
585                    }
586                }
587                Key::Char('\n') => {
588                    if let Some(index) = state.active_history_item_index {
589                        let answer = KeyValue::new(
590                            names[current_name_index],
591                            &filtered_history_items[index].value,
592                        );
593                        result.push(answer);
594                    } else if !&state.query.is_empty() {
595                        // Assume that an empty string answer is never what they want
596                        let answer = KeyValue::new(names[current_name_index], &state.query);
597
598                        let new_item = HistoryItem {
599                            name: answer.name.clone(),
600                            value: answer.value.clone(),
601                            env_name: env_name.to_string(),
602                        };
603
604                        if !full_history.contains(&new_item) {
605                            history_writer.write_record(&[
606                                answer.name.clone(),
607                                answer.value.clone(),
608                                env_name.to_string(),
609                            ])?;
610
611                            // Keep track of the new items so we can re-write the file at the end of
612                            // this function, which is necessary if the number of history items exceeds
613                            // the max_history_items setting in the user's Config
614                            created_items.push(new_item);
615                        }
616
617                        result.push(answer);
618                    }
619
620                    // If an answer was pushed, the means the current variable is done and we can
621                    // move on to the next one. We also want to start each variable in "query
622                    // mode", so we reset the active_history_item_index field.
623                    if result.len() == current_name_index + 1 {
624                        current_name_index += 1;
625                        state.active_history_item_index = None;
626                        state.query.clear();
627                        write!(terminal.backend_mut(), "{}", Show)?;
628                        if current_name_index >= names.len() {
629                            break;
630                        }
631                    }
632                }
633                Key::Backspace => {
634                    if !in_history_mode {
635                        state.query.pop();
636                    }
637                }
638                Key::Char(c) => {
639                    if !in_history_mode {
640                        state.query.push(c)
641                    }
642                }
643                _ => {}
644            }
645        }
646    }
647
648    // If the total number of history items exceeds the max, rewrite the history file with just the
649    // tail of appropriate size
650    let mut all_history = full_history;
651    all_history.append(&mut created_items);
652    let max = config.max_history_items.unwrap_or(1000) as usize;
653
654    if all_history.len() > max {
655        drop(history_writer);
656
657        let excess_items = all_history.len() - max;
658
659        let rewrite_file = OpenOptions::new()
660            .write(true)
661            .truncate(true)
662            .open(history_location.as_ref())?;
663        let mut history_rewriter = csv::Writer::from_writer(rewrite_file);
664        for item in all_history.iter().skip(excess_items) {
665            history_rewriter.write_record(&[
666                item.name.clone(),
667                item.value.clone(),
668                item.env_name.clone(),
669            ])?;
670        }
671    }
672
673    if result.len() == names.len() {
674        // All variables set, go ahead with the request
675        Ok(Some(result))
676    } else {
677        // The user aborted with Ctrl-C, don't send the request
678        Ok(None)
679    }
680}
681
682#[test]
683fn test_cut_to_current_word_start() {
684    let tests = vec![
685        ("one two three four", "one two three "),
686        ("one two three four ", "one two three "),
687        ("one ", ""),
688        ("one  ", ""),
689        ("one   two   three", "one   two   "),
690        ("a", ""),
691    ];
692
693    for (start, expected) in tests {
694        let mut s = start.to_owned();
695        cut_to_current_word_start(&mut s);
696        assert_eq!(s, expected)
697    }
698}