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
20pub 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 query: String,
39
40 list_state: ListState,
42
43 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 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 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 let mut environments: Vec<(Environment, PathBuf)> = files::list_all_environments(&config);
127
128 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 io::stdout().flush().ok();
141
142 let inner_guard = Arc::clone(&all_choices);
144 let inner_guard = inner_guard.read().unwrap();
145
146 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 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 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 app_state.list_state.select(None);
182 } else if app_state.list_state.selected().is_none() {
183 app_state.list_state.select(Some(0));
186 } else if let Some(selected) = app_state.list_state.selected() {
187 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 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 .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 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 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 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 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 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 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 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 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 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
362pub 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 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 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 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> = 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 let mut created_items: Vec<HistoryItem> = vec![];
449
450 let highlight_symbol = ">> ";
451
452 loop {
453 io::stdout().flush().ok();
454
455 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 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 let list_rows = std::cmp::min(
510 filtered_history_items.len() as u16,
511 height.checked_sub(2).unwrap_or(0),
512 );
513
514 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 let explanation_rect = tui::layout::Rect::new(0, height - 2, width, 1);
520 f.render_widget(explanation_widget, explanation_rect);
521
522 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 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 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 created_items.push(new_item);
615 }
616
617 result.push(answer);
618 }
619
620 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 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 Ok(Some(result))
676 } else {
677 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}