git_x/core/
interactive.rs1use crate::{GitXError, Result};
2use dialoguer::{Confirm, FuzzySelect, Input};
3use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
4
5pub struct Interactive;
7
8impl Interactive {
9 pub fn is_interactive() -> bool {
11 if std::env::var("CARGO_TARGET_TMPDIR").is_ok()
13 || std::env::var("CI").is_ok()
14 || std::env::var("GITHUB_ACTIONS").is_ok()
15 || std::env::var("GIT_X_NON_INTERACTIVE").is_ok()
16 || !atty::is(atty::Stream::Stdin)
17 {
18 return false;
19 }
20
21 true
22 }
23
24 pub fn fuzzy_select<T: AsRef<str> + Clone + ToString>(
26 items: &[T],
27 prompt: &str,
28 default: Option<usize>,
29 ) -> Result<T> {
30 let selection = FuzzySelect::new()
31 .with_prompt(prompt)
32 .items(items)
33 .default(default.unwrap_or(0))
34 .interact()
35 .map_err(|e| GitXError::GitCommand(format!("Selection cancelled: {e}")))?;
36
37 Ok(items[selection].clone())
38 }
39
40 pub fn branch_picker(branches: &[String], prompt: Option<&str>) -> Result<String> {
42 if branches.is_empty() {
43 return Err(GitXError::GitCommand("No branches available".to_string()));
44 }
45
46 let formatted_items: Vec<String> = branches
47 .iter()
48 .enumerate()
49 .map(|(i, branch)| {
50 let prefix = if i == 0 { "🌟 " } else { "📁 " };
51 format!("{prefix}{branch}")
52 })
53 .collect();
54
55 let prompt_text = prompt.unwrap_or("Select a branch");
56 let selection = FuzzySelect::new()
57 .with_prompt(prompt_text)
58 .items(&formatted_items)
59 .default(0)
60 .interact()
61 .map_err(|e| GitXError::GitCommand(format!("Selection cancelled: {e}")))?;
62
63 Ok(branches[selection].clone())
64 }
65
66 pub fn text_input(
68 prompt: &str,
69 default: Option<&str>,
70 validator: Option<fn(&str) -> Result<()>>,
71 ) -> Result<String> {
72 let mut input_builder = Input::<String>::new().with_prompt(prompt);
73
74 if let Some(default_val) = default {
75 input_builder = input_builder.default(default_val.to_string());
76 }
77
78 let input = input_builder
79 .interact_text()
80 .map_err(|e| GitXError::GitCommand(format!("Input cancelled: {e}")))?;
81
82 if let Some(validate_fn) = validator {
84 validate_fn(&input)?;
85 }
86
87 Ok(input)
88 }
89
90 pub fn confirm(prompt: &str, default: bool) -> Result<bool> {
92 Confirm::new()
93 .with_prompt(prompt)
94 .default(default)
95 .interact()
96 .map_err(|e| GitXError::GitCommand(format!("Confirmation cancelled: {e}")))
97 }
98
99 pub fn fuzzy_find<T: AsRef<str>>(
101 items: &[T],
102 query: &str,
103 limit: Option<usize>,
104 ) -> Vec<(usize, i64)> {
105 let matcher = SkimMatcherV2::default();
106 let mut results: Vec<(usize, i64)> = items
107 .iter()
108 .enumerate()
109 .filter_map(|(idx, item)| {
110 matcher
111 .fuzzy_match(item.as_ref(), query)
112 .map(|score| (idx, score))
113 })
114 .collect();
115
116 results.sort_by(|a, b| b.1.cmp(&a.1));
118
119 if let Some(limit) = limit {
120 results.truncate(limit);
121 }
122
123 results
124 }
125
126 pub fn select_or_first<T: AsRef<str> + Clone + ToString>(
128 items: &[T],
129 prompt: &str,
130 ) -> Result<T> {
131 if items.is_empty() {
132 return Err(GitXError::GitCommand("No items to select from".to_string()));
133 }
134
135 if !Self::is_interactive() {
136 return Ok(items[0].clone());
138 }
139
140 Self::fuzzy_select(items, prompt, Some(0))
141 }
142
143 pub fn confirm_or_accept(prompt: &str, default: bool) -> Result<bool> {
145 if !Self::is_interactive() {
146 return Ok(default);
148 }
149
150 Self::confirm(prompt, default)
151 }
152}
153
154pub struct InteractiveBuilder {
156 steps: Vec<InteractiveStep>,
157}
158
159#[derive(Debug)]
160enum InteractiveStep {
161 Confirm {
162 prompt: String,
163 default: bool,
164 },
165 Select {
166 prompt: String,
167 items: Vec<String>,
168 },
169 Input {
170 prompt: String,
171 default: Option<String>,
172 },
173}
174
175impl InteractiveBuilder {
176 pub fn new() -> Self {
177 Self { steps: Vec::new() }
178 }
179
180 pub fn confirm(mut self, prompt: &str, default: bool) -> Self {
181 self.steps.push(InteractiveStep::Confirm {
182 prompt: prompt.to_string(),
183 default,
184 });
185 self
186 }
187
188 pub fn select(mut self, prompt: &str, items: Vec<String>) -> Self {
189 self.steps.push(InteractiveStep::Select {
190 prompt: prompt.to_string(),
191 items,
192 });
193 self
194 }
195
196 pub fn input(mut self, prompt: &str, default: Option<String>) -> Self {
197 self.steps.push(InteractiveStep::Input {
198 prompt: prompt.to_string(),
199 default,
200 });
201 self
202 }
203
204 pub fn execute(self) -> Result<InteractiveResults> {
205 let mut results = InteractiveResults::new();
206
207 for step in self.steps {
208 match step {
209 InteractiveStep::Confirm { prompt, default } => {
210 let result = Interactive::confirm_or_accept(&prompt, default)?;
211 results.confirmations.push(result);
212 }
213 InteractiveStep::Select { prompt, items } => {
214 let result = Interactive::select_or_first(&items, &prompt)?;
215 results.selections.push(result);
216 }
217 InteractiveStep::Input { prompt, default } => {
218 let result = Interactive::text_input(&prompt, default.as_deref(), None)?;
219 results.inputs.push(result);
220 }
221 }
222 }
223
224 Ok(results)
225 }
226}
227
228impl Default for InteractiveBuilder {
229 fn default() -> Self {
230 Self::new()
231 }
232}
233
234#[derive(Debug)]
235pub struct InteractiveResults {
236 pub confirmations: Vec<bool>,
237 pub selections: Vec<String>,
238 pub inputs: Vec<String>,
239}
240
241impl InteractiveResults {
242 fn new() -> Self {
243 Self {
244 confirmations: Vec::new(),
245 selections: Vec::new(),
246 inputs: Vec::new(),
247 }
248 }
249
250 pub fn get_confirmation(&self, index: usize) -> Option<bool> {
251 self.confirmations.get(index).copied()
252 }
253
254 pub fn get_selection(&self, index: usize) -> Option<&str> {
255 self.selections.get(index).map(|s| s.as_str())
256 }
257
258 pub fn get_input(&self, index: usize) -> Option<&str> {
259 self.inputs.get(index).map(|s| s.as_str())
260 }
261}