1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
use crate::error::Result;
use crate::platform::Platform;
use crate::resolver::DiscoveredBundle;
use console::Style;
use inquire::MultiSelect;
use std::collections::HashSet;
/// Strip ANSI escape codes from a string
fn strip_ansi_codes(s: &str) -> String {
// Simple ANSI code removal - removes escape sequences like \x1b[0m, \x1b[2m, etc.
let mut result = String::new();
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
// Skip until 'm' (end of ANSI code)
while let Some(&next) = chars.peek() {
chars.next();
if next == 'm' {
break;
}
}
} else {
result.push(ch);
}
}
result.trim().to_string()
}
/// Scorer that matches only the bundle name (before " (" or " · "), so filtering
/// by typing does not match words in resource counts or descriptions.
fn score_by_name(input: &str, _opt: &String, string_value: &str, _idx: usize) -> Option<i64> {
// Remove ANSI codes before extracting name
let clean = strip_ansi_codes(string_value);
let name = clean
.split(" (")
.next()
.unwrap_or(&clean)
.split(" · ")
.next()
.unwrap_or(&clean)
.trim();
if input.is_empty() {
return Some(0);
}
if name.to_lowercase().contains(&input.to_lowercase()) {
Some(0)
} else {
None
}
}
/// Result of bundle selection - contains selected bundles and bundles that were deselected
pub struct BundleSelection {
pub selected: Vec<DiscoveredBundle>,
pub deselected: Vec<String>, // Names of bundles that were preselected but deselected
}
pub fn select_bundles_interactively(
discovered: &[DiscoveredBundle],
installed_bundle_names: Option<&HashSet<String>>,
) -> Result<BundleSelection> {
if discovered.is_empty() {
return Ok(BundleSelection {
selected: vec![],
deselected: vec![],
});
}
// Sort bundles alphabetically by name for display only
let mut sorted_bundles = discovered.to_vec();
sorted_bundles.sort_by(|a, b| a.name.cmp(&b.name));
// Create a map from bundle name to bundle for quick lookup while preserving original order
let bundle_map: std::collections::HashMap<String, DiscoveredBundle> = discovered
.iter()
.map(|b| (b.name.clone(), b.clone()))
.collect();
// Track which bundles are installed
let installed = installed_bundle_names.as_ref();
// Style for installed bundles (dimmed/gray)
let installed_style = Style::new().dim();
// Build list of default selections (indices of installed bundles) first
let default_selections: Vec<usize> = sorted_bundles
.iter()
.enumerate()
.filter_map(|(idx, b)| {
if installed.map(|set| set.contains(&b.name)).unwrap_or(false) {
Some(idx)
} else {
None
}
})
.collect();
// Single-line items: "name (1 command)" or "name · desc..." or "name (installed)".
// Multi-line content breaks inquire's list layout and causes the filter to match descriptions.
let items: Vec<String> = sorted_bundles
.iter()
.map(|b| {
let mut s = b.name.clone();
// Mark installed bundles with styled text
if installed.map(|set| set.contains(&b.name)).unwrap_or(false) {
s.push(' ');
s.push_str(&installed_style.apply_to("(installed)").to_string());
} else if let Some(formatted) = b.resource_counts.format() {
s.push_str(" (");
s.push_str(&formatted);
s.push(')');
}
if let Some(desc) = &b.description {
let trunc: String = if desc.chars().count() > 40 {
desc.chars().take(37).chain("...".chars()).collect()
} else {
desc.clone()
};
s.push_str(" · ");
s.push_str(&trunc);
}
s
})
.collect();
println!();
let mut multiselect = MultiSelect::new("Select bundles to install", items)
.with_page_size(10)
.with_help_message(
" ↑↓ navigate space select enter confirm type to filter q/esc cancel",
)
.with_scorer(&score_by_name);
// Preselect installed bundles if any exist
if !default_selections.is_empty() {
multiselect = multiselect.with_default(&default_selections);
}
let selection = match multiselect.prompt_skippable()? {
Some(sel) => sel,
None => {
return Ok(BundleSelection {
selected: vec![],
deselected: vec![],
});
}
};
// Map display strings back to DiscoveredBundle preserving selection order
// Note: We allow reinstalling already-installed bundles, they're just shown in different color
// IMPORTANT: Use bundle_map to preserve original discovery order, not sorted_bundles which is alphabetical
let selected_bundles: Vec<DiscoveredBundle> = selection
.iter()
.filter_map(|s| {
// Extract bundle name from display string
// The string might contain ANSI codes and "(installed)" marker
// Remove ANSI escape sequences first
let clean = strip_ansi_codes(s);
// Extract name part (before first " (" or " · ")
let name = clean
.split(" (")
.next()
.unwrap_or(&clean)
.split(" · ")
.next()
.unwrap_or(&clean)
.trim();
// Find matching bundle by name from the map (preserves original order)
bundle_map.get(name).cloned()
})
.collect();
// Find bundles that were preselected but deselected
// Note: installed_bundle_names contains discovered bundle names that are installed,
// not the full installed bundle names from lockfile
let selected_names: HashSet<String> = selected_bundles.iter().map(|b| b.name.clone()).collect();
let deselected: Vec<String> = if let Some(installed) = installed_bundle_names {
installed
.iter()
.filter(|name| {
// Check if this installed bundle was in the discovered list and is now deselected
!selected_names.contains(*name)
})
.cloned()
.collect()
} else {
Vec::new()
};
Ok(BundleSelection {
selected: selected_bundles,
deselected,
})
}
pub fn select_platforms_interactively(available_platforms: &[Platform]) -> Result<Vec<Platform>> {
if available_platforms.is_empty() {
return Ok(vec![]);
}
// Sort platforms alphabetically by name
let mut sorted_platforms = available_platforms.to_vec();
sorted_platforms.sort_by(|a, b| a.name.cmp(&b.name));
// Single-line items: "name (id)" format
let items: Vec<String> = sorted_platforms
.iter()
.map(|p| format!("{} ({})", p.name, p.id))
.collect();
println!();
let selection = match MultiSelect::new("Select platforms to install for", items)
.with_page_size(10)
.with_help_message(
" ↑↓ navigate space select enter confirm type to filter q/esc cancel",
)
.prompt_skippable()?
{
Some(sel) => sel,
None => return Ok(vec![]),
};
// Map display strings back to Platform
let selected_platforms: Vec<Platform> = selection
.iter()
.filter_map(|s| {
// Extract platform ID from "name (id)" format
if let Some(start) = s.rfind(" (") {
if let Some(end) = s.rfind(')') {
let id = &s[start + 2..end];
sorted_platforms.iter().find(|p| p.id == id).cloned()
} else {
None
}
} else {
None
}
})
.collect();
Ok(selected_platforms)
}