Skip to main content

git_same/setup/screens/
path.rs

1//! Step 4: Base path screen with folder navigation and live preview.
2
3use 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),           // Title + info
27        Constraint::Length(3),           // Input
28        Constraint::Length(list_height), // Suggestions or completions
29        Constraint::Min(3),              // Preview + error
30    ])
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    // Title and info (above input)
46    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    // Path input with styled border
63    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    // Suggestions or completions list
91    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    // Preview + error
98    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), // header
139        Constraint::Length(1), // path
140        Constraint::Min(3),    // tree
141        Constraint::Length(if show_message { 1 } else { 0 }),
142        Constraint::Length(1), // footer
143    ])
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;