1use super::*;
5use omnyssh_core::config::snippets::SnippetScope;
6use omnyssh_core::ssh::session::SshSession;
7
8#[derive(Debug, Clone)]
14pub struct SnippetResultEntry {
15 pub host_name: String,
16 pub snippet_name: String,
17 pub output: Result<String, String>,
19 pub pending: bool,
21}
22
23#[derive(Debug)]
26pub enum SnippetPopup {
27 Add(SnippetForm),
29 Edit {
31 snippet_idx: usize,
32 form: SnippetForm,
33 },
34 DeleteConfirm(usize),
36 ParamInput {
38 snippet_idx: usize,
39 host_names: Vec<String>,
40 param_names: Vec<String>,
41 param_fields: Vec<FormField>,
42 focused_field: usize,
43 },
44 BroadcastPicker {
46 snippet_idx: usize,
47 selected_host_indices: Vec<usize>,
49 cursor: usize,
51 },
52 QuickExecuteInput {
54 host_name: String,
55 command_field: FormField,
56 },
57 Results {
59 entries: Vec<SnippetResultEntry>,
60 scroll: usize,
61 },
62}
63
64pub const SNIPPET_FORM_FIELD_LABELS: &[&str] = &[
66 "Name",
67 "Command",
68 "Scope (global / host)",
69 "Host (if scope=host)",
70 "Tags (comma-sep)",
71 "Params (comma-sep)",
72];
73
74#[derive(Debug, Clone)]
76pub struct SnippetForm {
77 pub fields: Vec<FormField>,
79 pub focused_field: usize,
80}
81
82impl SnippetForm {
83 pub fn empty() -> Self {
85 Self {
86 fields: SNIPPET_FORM_FIELD_LABELS
87 .iter()
88 .map(|_| FormField::default())
89 .collect(),
90 focused_field: 0,
91 }
92 }
93
94 pub fn from_snippet(s: &Snippet) -> Self {
96 let mut form = Self::empty();
97 form.fields[0] = FormField::with_value(&s.name);
98 form.fields[1] = FormField::with_value(&s.command);
99 form.fields[2] = FormField::with_value(match s.scope {
100 SnippetScope::Global => "global",
101 SnippetScope::Host => "host",
102 });
103 form.fields[3] = FormField::with_value(s.host.as_deref().unwrap_or(""));
104 form.fields[4] = FormField::with_value(s.tags.as_deref().unwrap_or(&[]).join(", "));
105 form.fields[5] = FormField::with_value(s.params.as_deref().unwrap_or(&[]).join(", "));
106 form
107 }
108
109 pub fn to_snippet(&self) -> Result<Snippet, String> {
114 let name = self.fields[0].value.trim().to_string();
115 if name.is_empty() {
116 return Err("Name cannot be empty".to_string());
117 }
118
119 let command = self.fields[1].value.trim().to_string();
120 if command.is_empty() {
121 return Err("Command cannot be empty".to_string());
122 }
123
124 let scope_str = self.fields[2].value.trim().to_lowercase();
125 let scope = match scope_str.as_str() {
126 "host" => SnippetScope::Host,
127 _ => SnippetScope::Global, };
129
130 let host = {
131 let v = self.fields[3].value.trim();
132 if scope == SnippetScope::Host && v.is_empty() {
133 return Err("Host cannot be empty when scope is 'host'".to_string());
134 }
135 if v.is_empty() {
136 None
137 } else {
138 Some(v.to_string())
139 }
140 };
141
142 let tags: Vec<String> = self.fields[4]
143 .value
144 .split(',')
145 .map(|t| t.trim().to_string())
146 .filter(|t| !t.is_empty())
147 .collect();
148
149 let params: Vec<String> = self.fields[5]
150 .value
151 .split(',')
152 .map(|p| p.trim().to_string())
153 .filter(|p| !p.is_empty())
154 .collect();
155
156 Ok(Snippet {
157 name,
158 command,
159 scope,
160 host,
161 tags: if tags.is_empty() { None } else { Some(tags) },
162 params: if params.is_empty() {
163 None
164 } else {
165 Some(params)
166 },
167 })
168 }
169
170 pub fn focus_next(&mut self) {
172 self.focused_field = (self.focused_field + 1) % self.fields.len();
173 }
174
175 pub fn focus_prev(&mut self) {
177 if self.focused_field == 0 {
178 self.focused_field = self.fields.len() - 1;
179 } else {
180 self.focused_field -= 1;
181 }
182 }
183}
184
185#[derive(Debug, Default)]
187pub struct SnippetsView {
188 pub selected: usize,
190 pub search_mode: bool,
192 pub search_query: String,
194 pub filtered_indices: Vec<usize>,
196 pub popup: Option<SnippetPopup>,
198}
199
200impl SnippetsView {
201 pub fn selected_snippet_idx(&self) -> Option<usize> {
203 self.filtered_indices.get(self.selected).copied()
204 }
205
206 pub fn rebuild_filter(&mut self, snippets: &[Snippet], query: &str) {
208 self.filtered_indices = filter_snippets(snippets, query);
209 if self.filtered_indices.is_empty() {
210 self.selected = 0;
211 } else if self.selected >= self.filtered_indices.len() {
212 self.selected = self.filtered_indices.len() - 1;
213 }
214 }
215
216 pub fn select_next(&mut self) {
217 if self.filtered_indices.is_empty() {
218 return;
219 }
220 self.selected = (self.selected + 1).min(self.filtered_indices.len() - 1);
221 }
222
223 pub fn select_prev(&mut self) {
224 self.selected = self.selected.saturating_sub(1);
225 }
226}
227
228pub fn filter_snippets(snippets: &[Snippet], query: &str) -> Vec<usize> {
230 if query.is_empty() {
231 return (0..snippets.len()).collect();
232 }
233 let q = query.to_lowercase();
234 snippets
235 .iter()
236 .enumerate()
237 .filter(|(_, s)| {
238 s.name.to_lowercase().contains(&q)
239 || s.command.to_lowercase().contains(&q)
240 || s.tags
241 .as_deref()
242 .unwrap_or(&[])
243 .iter()
244 .any(|t| t.to_lowercase().contains(&q))
245 })
246 .map(|(i, _)| i)
247 .collect()
248}
249
250impl App {
251 pub(crate) async fn execute_snippet(&mut self, snippet_idx: usize, host_names: Vec<String>) {
258 let snippet = {
259 let state = self.state.read().await;
260 state.snippets.get(snippet_idx).cloned()
261 };
262 let Some(snippet) = snippet else { return };
263
264 let param_names: Vec<String> = snippet.params.as_deref().unwrap_or(&[]).to_vec();
265
266 if !param_names.is_empty() {
267 let param_fields = param_names.iter().map(|_| FormField::default()).collect();
269 self.view.snippets_view.popup = Some(SnippetPopup::ParamInput {
270 snippet_idx,
271 host_names,
272 param_names,
273 param_fields,
274 focused_field: 0,
275 });
276 } else {
277 self.spawn_snippet_tasks(&snippet, &host_names, &[]).await;
278 }
279 }
280
281 pub(crate) async fn handle_confirm_param_input(&mut self) {
284 let popup = self.view.snippets_view.popup.take();
285 match popup {
286 Some(SnippetPopup::ParamInput {
287 snippet_idx,
288 host_names,
289 param_names,
290 param_fields,
291 ..
292 }) => {
293 let param_values: Vec<String> = param_fields
294 .iter()
295 .map(|f| f.value.trim().to_string())
296 .collect();
297
298 let snippet = {
299 let state = self.state.read().await;
300 state.snippets.get(snippet_idx).cloned()
301 };
302 if let Some(snippet) = snippet {
303 self.spawn_snippet_tasks(&snippet, &host_names, ¶m_values)
304 .await;
305 }
306 let _ = (param_names,); }
310 other => {
311 self.view.snippets_view.popup = other;
313 }
314 }
315 }
316
317 async fn spawn_snippet_tasks(
320 &mut self,
321 snippet: &Snippet,
322 host_names: &[String],
323 param_values: &[String],
324 ) {
325 let command = substitute_params(&snippet.command, snippet.params.as_deref(), param_values);
326
327 let entries: Vec<SnippetResultEntry> = host_names
329 .iter()
330 .map(|h| SnippetResultEntry {
331 host_name: h.clone(),
332 snippet_name: snippet.name.clone(),
333 output: Ok(String::new()),
334 pending: true,
335 })
336 .collect();
337 self.view.snippets_view.popup = Some(SnippetPopup::Results { entries, scroll: 0 });
338
339 let hosts: Vec<Host> = {
341 let state = self.state.read().await;
342 host_names
343 .iter()
344 .filter_map(|name| state.hosts.iter().find(|h| &h.name == name).cloned())
345 .collect()
346 };
347
348 for host in hosts {
350 let tx = self.core_tx.clone();
351 let cmd = command.clone();
352 let sname = snippet.name.clone();
353 tokio::spawn(async move {
354 let result = run_command_on_host(&host, &cmd).await;
355 let _ = tx
356 .send(CoreEvent::SnippetResult {
357 host_name: host.name.clone(),
358 snippet_name: sname,
359 output: result,
360 })
361 .await;
362 });
363 }
364 }
365
366 pub(crate) async fn handle_confirm_broadcast(&mut self) {
368 let popup = self.view.snippets_view.popup.take();
369 match popup {
370 Some(SnippetPopup::BroadcastPicker {
371 snippet_idx,
372 selected_host_indices,
373 ..
374 }) => {
375 if selected_host_indices.is_empty() {
376 self.view.status_message = Some("No hosts selected.".to_string());
377 return;
378 }
379 let host_names: Vec<String> = {
380 let state = self.state.read().await;
381 selected_host_indices
382 .iter()
383 .filter_map(|&i| state.hosts.get(i))
384 .map(|h| h.name.clone())
385 .collect()
386 };
387 self.execute_snippet(snippet_idx, host_names).await;
388 }
389 other => {
390 self.view.snippets_view.popup = other;
391 }
392 }
393 }
394
395 pub(crate) async fn run_quick_execute(&mut self, host_name: String, command: String) {
397 let host = {
398 let state = self.state.read().await;
399 state.hosts.iter().find(|h| h.name == host_name).cloned()
400 };
401
402 let Some(host) = host else {
403 self.view.snippets_view.popup = Some(SnippetPopup::Results {
404 entries: vec![SnippetResultEntry {
405 host_name: host_name.clone(),
406 snippet_name: "(quick-execute)".to_string(),
407 output: Err(format!("Host '{}' not found.", host_name)),
408 pending: false,
409 }],
410 scroll: 0,
411 });
412 return;
413 };
414
415 self.view.snippets_view.popup = Some(SnippetPopup::Results {
417 entries: vec![SnippetResultEntry {
418 host_name: host_name.clone(),
419 snippet_name: "(quick-execute)".to_string(),
420 output: Ok(String::new()),
421 pending: true,
422 }],
423 scroll: 0,
424 });
425
426 let tx = self.core_tx.clone();
427 let cmd = command.clone();
428 tokio::spawn(async move {
429 let result = run_command_on_host(&host, &cmd).await;
430 let _ = tx
431 .send(CoreEvent::SnippetResult {
432 host_name: host.name.clone(),
433 snippet_name: "(quick-execute)".to_string(),
434 output: result,
435 })
436 .await;
437 });
438 }
439
440 pub(crate) async fn execute_quick_view(&mut self, service_kind: ServiceKind) {
443 let (host, host_name) = {
445 let state = self.state.read().await;
446 match self.view.host_list.selected_host_idx() {
447 Some(idx) => match state.hosts.get(idx) {
448 Some(h) => (Some(h.clone()), h.name.clone()),
449 None => (None, "(unknown)".to_string()),
450 },
451 None => (None, "(no selection)".to_string()),
452 }
453 };
454
455 let Some(host) = host else {
456 self.view.snippets_view.popup = Some(SnippetPopup::Results {
457 entries: vec![SnippetResultEntry {
458 host_name: host_name.clone(),
459 snippet_name: format!("Quick View: {:?}", service_kind),
460 output: Err("No host selected.".to_string()),
461 pending: false,
462 }],
463 scroll: 0,
464 });
465 return;
466 };
467
468 let (command, service_name) = match service_kind {
470 ServiceKind::Docker => (
471 "docker compose ps -a 2>/dev/null || docker ps -a",
472 "Docker Containers",
473 ),
474 ServiceKind::Nginx => (
475 "echo '=== Nginx Status ===' && systemctl status nginx --no-pager || service nginx status",
476 "Nginx Status",
477 ),
478 ServiceKind::PostgreSQL => (
479 "echo '=== PostgreSQL Connections ===' && sudo -u postgres psql -c 'SELECT count(*) as connections, state FROM pg_stat_activity GROUP BY state;' 2>/dev/null || echo 'No access to PostgreSQL'",
480 "PostgreSQL Connections",
481 ),
482 ServiceKind::Redis => (
483 "echo '=== Redis Info ===' && redis-cli info server 2>/dev/null | head -20 || echo 'Redis not accessible'",
484 "Redis Info",
485 ),
486 ServiceKind::NodeJS => (
487 "echo '=== PM2 Status ===' && pm2 status 2>/dev/null || (echo '=== Node Processes ===' && ps aux | grep -E '[n]ode ' | head -10)",
488 "Node.js Processes",
489 ),
490 };
491
492 self.view.snippets_view.popup = Some(SnippetPopup::Results {
494 entries: vec![SnippetResultEntry {
495 host_name: host_name.clone(),
496 snippet_name: format!("Quick View: {}", service_name),
497 output: Ok(String::new()),
498 pending: true,
499 }],
500 scroll: 0,
501 });
502
503 let tx = self.core_tx.clone();
504 let cmd = command.to_string();
505 let sname = format!("Quick View: {}", service_name);
506 tokio::spawn(async move {
507 let result = run_command_on_host(&host, &cmd).await;
508 let _ = tx
509 .send(CoreEvent::SnippetResult {
510 host_name: host.name.clone(),
511 snippet_name: sname,
512 output: result,
513 })
514 .await;
515 });
516 }
517
518 pub(crate) async fn handle_confirm_snippet_form(&mut self) {
520 match self.view.snippets_view.popup.take() {
521 Some(SnippetPopup::Add(form)) => match form.to_snippet() {
522 Ok(snippet) => {
523 {
524 let mut state = self.state.write().await;
525 state.snippets.push(snippet);
526 }
527 self.save_snippets().await;
528 let state = self.state.read().await;
529 let q = self.view.snippets_view.search_query.clone();
530 self.view.snippets_view.rebuild_filter(&state.snippets, &q);
531 self.view.status_message = Some("Snippet added.".to_string());
532 }
533 Err(e) => {
534 self.view.snippets_view.popup = Some(SnippetPopup::Add(form));
535 self.view.status_message = Some(format!("Error: {e}"));
536 }
537 },
538
539 Some(SnippetPopup::Edit { snippet_idx, form }) => match form.to_snippet() {
540 Ok(snippet) => {
541 {
542 let mut state = self.state.write().await;
543 if let Some(slot) = state.snippets.get_mut(snippet_idx) {
544 *slot = snippet;
545 }
546 }
547 self.save_snippets().await;
548 let state = self.state.read().await;
549 let q = self.view.snippets_view.search_query.clone();
550 self.view.snippets_view.rebuild_filter(&state.snippets, &q);
551 self.view.status_message = Some("Snippet updated.".to_string());
552 }
553 Err(e) => {
554 self.view.snippets_view.popup = Some(SnippetPopup::Edit { snippet_idx, form });
555 self.view.status_message = Some(format!("Error: {e}"));
556 }
557 },
558
559 other => {
560 self.view.snippets_view.popup = other;
561 }
562 }
563 }
564
565 pub(crate) async fn handle_confirm_snippet_delete(&mut self) {
567 if let Some(SnippetPopup::DeleteConfirm(idx)) = self.view.snippets_view.popup.take() {
568 {
569 let mut state = self.state.write().await;
570 if idx < state.snippets.len() {
571 let removed = state.snippets.remove(idx);
572 self.view.status_message = Some(format!("Deleted snippet '{}'.", removed.name));
573 }
574 }
575 self.save_snippets().await;
576 let state = self.state.read().await;
577 let q = self.view.snippets_view.search_query.clone();
578 self.view.snippets_view.rebuild_filter(&state.snippets, &q);
579 }
580 }
581
582 async fn save_snippets(&mut self) {
584 let snippets = self.state.read().await.snippets.clone();
585 if let Err(e) = config::snippets::save_snippets(&snippets) {
586 self.view.status_message = Some(format!("Save failed: {e}"));
587 }
588 }
589}
590
591async fn run_command_on_host(host: &Host, command: &str) -> Result<String, String> {
601 let session = SshSession::connect(host)
602 .await
603 .map_err(|e| format!("Connect failed: {e}"))?;
604 let output = session
605 .run_command(command)
606 .await
607 .map_err(|e| format!("Command failed: {e}"))?;
608 let _ = session.disconnect().await;
609 Ok(output)
610}
611
612pub(crate) fn substitute_params(
615 command: &str,
616 param_names: Option<&[String]>,
617 param_values: &[String],
618) -> String {
619 let mut result = command.to_string();
620 if let Some(names) = param_names {
621 for (name, value) in names.iter().zip(param_values.iter()) {
622 let placeholder = format!("{{{{{}}}}}", name);
623 result = result.replace(&placeholder, value);
624 }
625 }
626 result
627}
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632
633 fn s(v: &str) -> String {
634 v.to_string()
635 }
636
637 #[test]
640 fn subst_single_placeholder() {
641 let out = substitute_params("echo {{name}}", Some(&[s("name")]), &[s("world")]);
642 assert_eq!(out, "echo world");
643 }
644
645 #[test]
646 fn subst_multiple_placeholders() {
647 let out = substitute_params("{{a}}-{{b}}", Some(&[s("a"), s("b")]), &[s("x"), s("y")]);
648 assert_eq!(out, "x-y");
649 }
650
651 #[test]
652 fn subst_repeated_placeholder_replaces_all() {
653 let out = substitute_params("{{x}} {{x}}", Some(&[s("x")]), &[s("v")]);
654 assert_eq!(out, "v v");
655 }
656
657 #[test]
658 fn subst_no_params_returns_command_unchanged() {
659 let out = substitute_params("echo hi", None, &[]);
660 assert_eq!(out, "echo hi");
661 }
662
663 #[test]
664 fn subst_empty_value_removes_placeholder() {
665 let out = substitute_params("a{{p}}b", Some(&[s("p")]), &[s("")]);
666 assert_eq!(out, "ab");
667 }
668
669 #[test]
670 fn subst_missing_value_leaves_placeholder_literal() {
671 let out = substitute_params("{{a}} {{b}}", Some(&[s("a"), s("b")]), &[s("x")]);
673 assert_eq!(out, "x {{b}}");
674 }
675
676 #[test]
677 fn subst_extra_values_ignored() {
678 let out = substitute_params("{{a}}", Some(&[s("a")]), &[s("x"), s("y")]);
679 assert_eq!(out, "x");
680 }
681
682 #[test]
683 fn subst_unused_name_is_noop() {
684 let out = substitute_params("echo hi", Some(&[s("unused")]), &[s("v")]);
685 assert_eq!(out, "echo hi");
686 }
687
688 #[test]
689 fn subst_value_inserted_verbatim_no_shell_escaping() {
690 let out = substitute_params("sh -c {{cmd}}", Some(&[s("cmd")]), &[s("; rm -rf /")]);
692 assert_eq!(out, "sh -c ; rm -rf /");
693 }
694
695 #[test]
696 fn subst_unknown_braces_in_value_not_reexpanded() {
697 let out = substitute_params("{{a}}", Some(&[s("a")]), &[s("{{evil}}")]);
698 assert_eq!(out, "{{evil}}");
699 }
700
701 #[test]
702 fn subst_single_brace_untouched() {
703 let out = substitute_params("{name}", Some(&[s("name")]), &[s("v")]);
704 assert_eq!(out, "{name}");
705 }
706
707 #[test]
708 fn subst_name_with_spaces() {
709 let out = substitute_params("{{db name}}", Some(&[s("db name")]), &[s("v")]);
710 assert_eq!(out, "v");
711 }
712
713 fn snippet_form(values: [&str; 6]) -> SnippetForm {
715 let mut form = SnippetForm::empty();
716 for (i, v) in values.iter().enumerate() {
717 form.fields[i] = FormField::with_value(*v);
718 }
719 form
720 }
721
722 fn snippet(name: &str, command: &str, tags: &[&str]) -> Snippet {
723 Snippet {
724 name: name.to_string(),
725 command: command.to_string(),
726 scope: SnippetScope::Global,
727 host: None,
728 tags: if tags.is_empty() {
729 None
730 } else {
731 Some(tags.iter().map(|t| t.to_string()).collect())
732 },
733 params: None,
734 }
735 }
736
737 #[test]
740 fn to_snippet_empty_name_errs() {
741 assert!(snippet_form(["", "cmd", "global", "", "", ""])
742 .to_snippet()
743 .is_err());
744 }
745
746 #[test]
747 fn to_snippet_empty_command_errs() {
748 assert!(snippet_form(["n", "", "global", "", "", ""])
749 .to_snippet()
750 .is_err());
751 }
752
753 #[test]
754 fn to_snippet_scope_defaults_global() {
755 let snip = snippet_form(["n", "cmd", "", "", "", ""])
756 .to_snippet()
757 .unwrap();
758 assert_eq!(snip.scope, SnippetScope::Global);
759 }
760
761 #[test]
762 fn to_snippet_scope_host_explicit() {
763 let snip = snippet_form(["n", "cmd", "host", "web", "", ""])
764 .to_snippet()
765 .unwrap();
766 assert_eq!(snip.scope, SnippetScope::Host);
767 }
768
769 #[test]
770 fn to_snippet_scope_case_insensitive() {
771 let snip = snippet_form(["n", "cmd", "HOST", "web", "", ""])
772 .to_snippet()
773 .unwrap();
774 assert_eq!(snip.scope, SnippetScope::Host);
775 }
776
777 #[test]
778 fn to_snippet_host_required_when_scope_host() {
779 assert!(snippet_form(["n", "cmd", "host", "", "", ""])
780 .to_snippet()
781 .is_err());
782 }
783
784 #[test]
785 fn to_snippet_host_kept_when_scope_global() {
786 let snip = snippet_form(["n", "cmd", "global", "web", "", ""])
787 .to_snippet()
788 .unwrap();
789 assert_eq!(snip.host.as_deref(), Some("web"));
790 }
791
792 #[test]
793 fn to_snippet_tags_params_split_and_trimmed() {
794 let snip = snippet_form(["n", "cmd", "global", "", "a, b ,,", "p1, p2"])
795 .to_snippet()
796 .unwrap();
797 assert_eq!(snip.tags, Some(vec!["a".to_string(), "b".to_string()]));
798 assert_eq!(snip.params, Some(vec!["p1".to_string(), "p2".to_string()]));
799 }
800
801 #[test]
802 fn to_snippet_tags_params_none_when_empty() {
803 let snip = snippet_form(["n", "cmd", "global", "", "", ""])
804 .to_snippet()
805 .unwrap();
806 assert!(snip.tags.is_none());
807 assert!(snip.params.is_none());
808 }
809
810 #[test]
813 fn filter_snippets_empty_query_returns_all() {
814 let snips = [snippet("a", "ls", &[]), snippet("b", "pwd", &[])];
815 assert_eq!(filter_snippets(&snips, ""), vec![0, 1]);
816 }
817
818 #[test]
819 fn filter_snippets_matches_name() {
820 let snips = [snippet("deploy", "ls", &[]), snippet("backup", "pwd", &[])];
821 assert_eq!(filter_snippets(&snips, "depl"), vec![0]);
822 }
823
824 #[test]
825 fn filter_snippets_matches_command() {
826 let snips = [
827 snippet("a", "systemctl restart", &[]),
828 snippet("b", "ls", &[]),
829 ];
830 assert_eq!(filter_snippets(&snips, "systemctl"), vec![0]);
831 }
832
833 #[test]
834 fn filter_snippets_matches_tag() {
835 let snips = [snippet("a", "ls", &["ops"]), snippet("b", "pwd", &["dev"])];
836 assert_eq!(filter_snippets(&snips, "ops"), vec![0]);
837 }
838
839 #[test]
840 fn filter_snippets_case_insensitive() {
841 let snips = [snippet("Deploy", "ls", &[])];
842 assert_eq!(filter_snippets(&snips, "DEPLOY"), vec![0]);
843 }
844
845 #[test]
846 fn filter_snippets_no_match_returns_empty() {
847 let snips = [snippet("a", "ls", &[])];
848 assert!(filter_snippets(&snips, "zzz").is_empty());
849 }
850
851 #[test]
854 fn snippetsview_rebuild_filter_sets_indices() {
855 let snips = [snippet("deploy", "ls", &[]), snippet("backup", "pwd", &[])];
856 let mut view = SnippetsView::default();
857 view.rebuild_filter(&snips, "depl");
858 assert_eq!(view.filtered_indices, vec![0]);
859 }
860
861 #[test]
862 fn snippetsview_rebuild_clamps_selection() {
863 let snips = [snippet("a", "ls", &[]), snippet("b", "pwd", &[])];
864 let mut view = SnippetsView {
865 selected: 5,
866 ..Default::default()
867 };
868 view.rebuild_filter(&snips, "");
869 assert_eq!(view.selected, 1);
870 }
871
872 #[test]
873 fn snippetsview_rebuild_empty_resets_selection() {
874 let snips = [snippet("a", "ls", &[])];
875 let mut view = SnippetsView {
876 selected: 3,
877 ..Default::default()
878 };
879 view.rebuild_filter(&snips, "zzz");
880 assert_eq!(view.selected, 0);
881 }
882
883 #[test]
884 fn snippetsview_select_next_clamps_at_last() {
885 let mut view = SnippetsView {
886 filtered_indices: vec![0, 1],
887 ..Default::default()
888 };
889 view.select_next();
890 view.select_next();
891 view.select_next();
892 assert_eq!(view.selected, 1);
893 }
894
895 #[test]
896 fn snippetsview_select_prev_saturates_at_zero() {
897 let mut view = SnippetsView {
898 filtered_indices: vec![0, 1],
899 ..Default::default()
900 };
901 view.select_prev();
902 assert_eq!(view.selected, 0);
903 }
904
905 #[test]
906 fn snippetsview_selected_idx_maps_through_filter() {
907 let view = SnippetsView {
908 filtered_indices: vec![3, 7],
909 selected: 1,
910 ..Default::default()
911 };
912 assert_eq!(view.selected_snippet_idx(), Some(7));
913 }
914}