1use crate::setup::state::SetupState;
4use ratatui::layout::{Alignment, Constraint, Layout, Rect};
5use ratatui::style::{Color, Modifier, Style};
6use ratatui::text::{Line, Span};
7use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
8use ratatui::Frame;
9
10pub fn render(state: &SetupState, frame: &mut Frame, area: Rect) {
11 let popup_open = state.path_browse_mode;
12 let list_items = if popup_open {
13 0
14 } else if state.path_suggestions_mode {
15 state.path_suggestions.len()
16 } else {
17 state.path_completions.len()
18 };
19 let list_height = if list_items > 0 {
20 (list_items as u16 + 1).min(7)
21 } else {
22 0
23 };
24
25 let chunks = Layout::vertical([
26 Constraint::Length(4), Constraint::Length(3), Constraint::Length(list_height), Constraint::Min(3), ])
31 .split(area);
32
33 let accent = if popup_open {
34 Color::DarkGray
35 } else {
36 Color::Cyan
37 };
38 let muted = Color::DarkGray;
39 let input_text_color = if popup_open {
40 Color::DarkGray
41 } else {
42 Color::White
43 };
44
45 let title_lines = vec![
47 Line::from(Span::styled(
48 " Where should repositories be cloned?",
49 Style::default().fg(accent).add_modifier(Modifier::BOLD),
50 )),
51 Line::from(Span::styled(
52 " Repos will be organized as: <path>/<org>/<repo>",
53 Style::default().fg(muted),
54 )),
55 Line::from(Span::styled(
56 " Base path starts at terminal folder. Press [b] to change it.",
57 Style::default().fg(muted),
58 )),
59 ];
60 frame.render_widget(Paragraph::new(title_lines), chunks[0]);
61
62 let input_style = Style::default().fg(input_text_color);
64
65 let input_line = Line::from(vec![
66 Span::styled(" ", Style::default()),
67 Span::styled(&state.base_path, input_style),
68 ]);
69 let border_type = if state.path_browse_mode {
70 BorderType::Thick
71 } else if state.path_suggestions_mode {
72 BorderType::Plain
73 } else {
74 BorderType::Thick
75 };
76 let border_color = if state.path_suggestions_mode {
77 Color::DarkGray
78 } else {
79 accent
80 };
81 let input = Paragraph::new(input_line).block(
82 Block::default()
83 .borders(Borders::ALL)
84 .title(" Base Path ")
85 .border_type(border_type)
86 .border_style(Style::default().fg(border_color)),
87 );
88 frame.render_widget(input, chunks[1]);
89
90 if state.path_suggestions_mode && !state.path_suggestions.is_empty() {
92 render_suggestions(state, frame, chunks[2]);
93 } else if !state.path_suggestions_mode && !state.path_completions.is_empty() {
94 render_completions(state, frame, chunks[2]);
95 }
96
97 let mut preview_lines: Vec<Line> = Vec::new();
99 let preview_path = &state.base_path;
100 if !preview_path.is_empty() {
101 preview_lines.push(Line::from(Span::styled(
102 " Preview:",
103 Style::default().fg(muted),
104 )));
105 preview_lines.push(Line::from(Span::styled(
106 format!(" {preview_path}/acme-corp/my-repo/"),
107 Style::default().fg(muted),
108 )));
109 }
110
111 if let Some(ref err) = state.error_message {
112 preview_lines.push(Line::raw(""));
113 preview_lines.push(Line::from(Span::styled(
114 format!(" {}", err),
115 Style::default().fg(muted),
116 )));
117 }
118
119 frame.render_widget(Paragraph::new(preview_lines), chunks[3]);
120 if popup_open {
121 render_browse_popup(state, frame, area);
122 }
123}
124
125fn render_browse_popup(state: &SetupState, frame: &mut Frame, area: Rect) {
126 let popup_area = centered_area(area, 80, 80);
127 frame.render_widget(Clear, popup_area);
128
129 let popup = Block::default()
130 .borders(Borders::ALL)
131 .border_type(BorderType::Thick)
132 .border_style(Style::default().fg(Color::Cyan));
133 let inner = popup.inner(popup_area);
134 frame.render_widget(popup, popup_area);
135
136 let show_message = state.path_browse_error.is_some() || state.path_browse_info.is_some();
137 let rows = Layout::vertical([
138 Constraint::Length(3), Constraint::Length(1), Constraint::Min(3), Constraint::Length(if show_message { 1 } else { 0 }),
142 Constraint::Length(1), ])
144 .split(inner);
145
146 render_popup_header(frame, rows[0]);
147
148 let path_line = Line::from(vec![
149 Span::styled("Path: ", Style::default().fg(Color::DarkGray)),
150 Span::styled(
151 &state.path_browse_current_dir,
152 Style::default()
153 .fg(Color::Cyan)
154 .add_modifier(Modifier::BOLD),
155 ),
156 ]);
157 frame.render_widget(Paragraph::new(path_line), rows[1]);
158
159 render_browse_tree(state, frame, rows[2]);
160
161 if show_message {
162 let message = state
163 .path_browse_error
164 .as_ref()
165 .map(|msg| {
166 (
167 msg.as_str(),
168 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
169 )
170 })
171 .or_else(|| {
172 state
173 .path_browse_info
174 .as_ref()
175 .map(|msg| (msg.as_str(), Style::default().fg(Color::DarkGray)))
176 });
177 if let Some((msg, style)) = message {
178 frame.render_widget(
179 Paragraph::new(Line::from(Span::styled(msg, style))),
180 rows[3],
181 );
182 }
183 }
184
185 render_popup_footer(frame, rows[4]);
186}
187
188fn render_popup_header(frame: &mut Frame, area: Rect) {
189 let header = Paragraph::new("Local Folder Navigator")
190 .style(
191 Style::default()
192 .fg(Color::Cyan)
193 .add_modifier(Modifier::BOLD),
194 )
195 .alignment(Alignment::Center)
196 .block(
197 Block::default()
198 .borders(Borders::ALL)
199 .border_type(BorderType::Rounded)
200 .border_style(Style::default().fg(Color::DarkGray)),
201 );
202 frame.render_widget(header, area);
203}
204
205fn render_browse_tree(state: &SetupState, frame: &mut Frame, area: Rect) {
206 if area.height == 0 {
207 return;
208 }
209 let mut lines: Vec<Line> = Vec::new();
210 if state.path_browse_entries.is_empty() {
211 lines.push(Line::from(Span::styled(
212 " (No folders available)",
213 Style::default().fg(Color::DarkGray),
214 )));
215 frame.render_widget(Paragraph::new(lines), area);
216 return;
217 }
218
219 let visible = area.height as usize;
220 let selection = state
221 .path_browse_index
222 .min(state.path_browse_entries.len().saturating_sub(1));
223 let half = visible / 2;
224 let mut start = selection.saturating_sub(half);
225 if start + visible > state.path_browse_entries.len() {
226 start = state.path_browse_entries.len().saturating_sub(visible);
227 }
228
229 for (i, entry) in state
230 .path_browse_entries
231 .iter()
232 .enumerate()
233 .skip(start)
234 .take(visible)
235 {
236 let is_selected = i == selection;
237 let pointer = if is_selected { "› " } else { " " };
238 let icon = if entry.has_children {
239 if entry.expanded {
240 "▾ "
241 } else {
242 "▸ "
243 }
244 } else {
245 " "
246 };
247 let style = if is_selected {
248 Style::default()
249 .fg(Color::Cyan)
250 .add_modifier(Modifier::BOLD)
251 } else {
252 Style::default().fg(Color::White)
253 };
254 lines.push(Line::from(vec![
255 Span::styled(pointer, style),
256 Span::styled(
257 " ".repeat(entry.depth as usize),
258 Style::default().fg(Color::DarkGray),
259 ),
260 Span::styled(icon, style),
261 Span::styled(&entry.label, style),
262 ]));
263 }
264
265 frame.render_widget(Paragraph::new(lines), area);
266}
267
268fn render_popup_footer(frame: &mut Frame, area: Rect) {
269 let left = "[Esc] Close";
270 let center = "[←] Parent [↑/↓] Move [→] Open";
271 let right = "[Enter] Select";
272 let cols = Layout::horizontal([
273 Constraint::Length(left.chars().count() as u16),
274 Constraint::Min(0),
275 Constraint::Length(right.chars().count() as u16),
276 ])
277 .split(area);
278
279 frame.render_widget(
280 Paragraph::new(Line::from(Span::styled(
281 left,
282 Style::default().fg(Color::DarkGray),
283 ))),
284 cols[0],
285 );
286 frame.render_widget(
287 Paragraph::new(Line::from(Span::styled(
288 center,
289 Style::default().fg(Color::Cyan),
290 )))
291 .alignment(Alignment::Center),
292 cols[1],
293 );
294 frame.render_widget(
295 Paragraph::new(Line::from(Span::styled(
296 right,
297 Style::default().fg(Color::DarkGray),
298 )))
299 .alignment(Alignment::Right),
300 cols[2],
301 );
302}
303
304fn centered_area(area: Rect, width_pct: u16, height_pct: u16) -> Rect {
305 let top = (100 - height_pct) / 2;
306 let bottom = 100 - height_pct - top;
307 let left = (100 - width_pct) / 2;
308 let right = 100 - width_pct - left;
309
310 let vertical = Layout::vertical([
311 Constraint::Percentage(top),
312 Constraint::Percentage(height_pct),
313 Constraint::Percentage(bottom),
314 ])
315 .split(area);
316 let horizontal = Layout::horizontal([
317 Constraint::Percentage(left),
318 Constraint::Percentage(width_pct),
319 Constraint::Percentage(right),
320 ])
321 .split(vertical[1]);
322 horizontal[1]
323}
324
325fn render_suggestions(state: &SetupState, frame: &mut Frame, area: Rect) {
326 let mut lines = vec![Line::from(Span::styled(
327 " Suggestions:",
328 Style::default().fg(Color::DarkGray),
329 ))];
330
331 for (i, suggestion) in state.path_suggestions.iter().enumerate() {
332 let is_selected = i == state.path_suggestion_index;
333 let marker = if is_selected { " \u{25b8} " } else { " " };
334 let path_style = if is_selected {
335 Style::default()
336 .fg(Color::Cyan)
337 .add_modifier(Modifier::BOLD)
338 } else {
339 Style::default().fg(Color::White)
340 };
341
342 let mut spans = vec![
343 Span::styled(marker, path_style),
344 Span::styled(&suggestion.path, path_style),
345 ];
346 if !suggestion.label.is_empty() {
347 spans.push(Span::styled(
348 format!(" ({})", suggestion.label),
349 Style::default().fg(Color::DarkGray),
350 ));
351 }
352 lines.push(Line::from(spans));
353 }
354
355 frame.render_widget(Paragraph::new(lines), area);
356}
357
358fn render_completions(state: &SetupState, frame: &mut Frame, area: Rect) {
359 let mut lines: Vec<Line> = Vec::new();
360 for (i, path) in state.path_completions.iter().enumerate() {
361 if i >= 6 {
362 break;
363 }
364 let style = if i == state.path_completion_index {
365 Style::default().fg(Color::Yellow)
366 } else {
367 Style::default().fg(Color::DarkGray)
368 };
369 lines.push(Line::from(Span::styled(format!(" {path}"), style)));
370 }
371
372 frame.render_widget(Paragraph::new(lines), area);
373}
374
375#[cfg(test)]
376#[path = "path_tests.rs"]
377mod tests;