Skip to main content

git_same/tui/screens/
settings.rs

1//! Settings screen — two-pane layout with nav (left) and detail (right).
2//!
3//! Left sidebar: "Global" section with Requirements and Options.
4//! Right panel shows detail for the selected item.
5
6use ratatui::{
7    layout::{Constraint, Layout, Rect},
8    style::{Color, Modifier, Style},
9    text::{Line, Span},
10    widgets::{Block, Borders, List, ListItem, Paragraph},
11    Frame,
12};
13
14use crossterm::event::{KeyCode, KeyEvent};
15
16use crate::banner::render_banner;
17use crate::tui::app::App;
18
19pub fn handle_key(app: &mut App, key: KeyEvent) {
20    let num_items = 2; // Requirements, Options
21    match key.code {
22        KeyCode::Tab | KeyCode::Down => {
23            app.settings_index = (app.settings_index + 1) % num_items;
24        }
25        KeyCode::Up => {
26            app.settings_index = (app.settings_index + num_items - 1) % num_items;
27        }
28        KeyCode::Char('c') => {
29            // Open config directory in Finder / file manager
30            if let Ok(path) = crate::config::Config::default_path() {
31                if let Some(parent) = path.parent() {
32                    if let Err(e) = open_directory(parent) {
33                        app.error_message = Some(format!(
34                            "Failed to open config directory '{}': {}",
35                            parent.display(),
36                            e
37                        ));
38                    }
39                }
40            }
41        }
42        KeyCode::Char('d') => {
43            app.dry_run = !app.dry_run;
44        }
45        KeyCode::Char('m') => {
46            app.sync_pull = !app.sync_pull;
47        }
48        _ => {}
49    }
50}
51
52#[cfg(target_os = "macos")]
53fn open_directory(path: &std::path::Path) -> std::io::Result<()> {
54    std::process::Command::new("open").arg(path).spawn()?;
55    Ok(())
56}
57
58#[cfg(target_os = "windows")]
59fn open_directory(path: &std::path::Path) -> std::io::Result<()> {
60    std::process::Command::new("explorer").arg(path).spawn()?;
61    Ok(())
62}
63
64#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
65fn open_directory(path: &std::path::Path) -> std::io::Result<()> {
66    std::process::Command::new("xdg-open").arg(path).spawn()?;
67    Ok(())
68}
69
70pub fn render(app: &App, frame: &mut Frame) {
71    let chunks = Layout::vertical([
72        Constraint::Length(6), // Banner
73        Constraint::Length(3), // Title
74        Constraint::Min(5),    // Content (two panes)
75        Constraint::Length(2), // Bottom actions (2 lines)
76    ])
77    .split(frame.area());
78
79    render_banner(frame, chunks[0]);
80
81    // Title
82    let title = Paragraph::new(Line::from(vec![Span::styled(
83        " Settings ",
84        Style::default()
85            .fg(Color::Cyan)
86            .add_modifier(Modifier::BOLD),
87    )]))
88    .block(
89        Block::default()
90            .borders(Borders::ALL)
91            .border_style(Style::default().fg(Color::DarkGray)),
92    )
93    .centered();
94    frame.render_widget(title, chunks[1]);
95
96    // Two-pane split
97    let panes = Layout::horizontal([Constraint::Percentage(25), Constraint::Percentage(75)])
98        .split(chunks[2]);
99
100    render_category_nav(app, frame, panes[0]);
101
102    match app.settings_index {
103        0 => render_requirements_detail(app, frame, panes[1]),
104        1 => render_options_detail(app, frame, panes[1]),
105        _ => render_requirements_detail(app, frame, panes[1]),
106    }
107
108    render_bottom_actions(app, frame, chunks[3]);
109}
110
111fn render_category_nav(app: &App, frame: &mut Frame, area: Rect) {
112    let header_style = Style::default()
113        .fg(Color::White)
114        .add_modifier(Modifier::BOLD);
115
116    let items: Vec<ListItem> = vec![
117        // -- Global header --
118        ListItem::new(Line::from(Span::styled("  Global", header_style))),
119        // Requirements (index 0)
120        nav_item("Requirements", app.settings_index == 0),
121        // Options (index 1)
122        nav_item("Options", app.settings_index == 1),
123    ];
124
125    let list = List::new(items).block(
126        Block::default()
127            .borders(Borders::ALL)
128            .border_style(Style::default().fg(Color::DarkGray)),
129    );
130    frame.render_widget(list, area);
131}
132
133fn nav_item(label: &str, selected: bool) -> ListItem<'static> {
134    let (marker, style) = if selected {
135        (
136            ">",
137            Style::default()
138                .fg(Color::Cyan)
139                .add_modifier(Modifier::BOLD),
140        )
141    } else {
142        (" ", Style::default())
143    };
144    ListItem::new(Line::from(vec![
145        Span::styled(format!("  {} ", marker), style),
146        Span::styled(label.to_string(), style),
147    ]))
148}
149
150fn render_requirements_detail(app: &App, frame: &mut Frame, area: Rect) {
151    let dim = Style::default().fg(Color::DarkGray);
152    let section_style = Style::default()
153        .fg(Color::White)
154        .add_modifier(Modifier::BOLD);
155    let pass_style = Style::default()
156        .fg(Color::Rgb(21, 128, 61))
157        .add_modifier(Modifier::BOLD);
158    let fail_style = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD);
159
160    let mut lines = vec![
161        Line::from(""),
162        Line::from(Span::styled("  Requirements", section_style)),
163        Line::from(""),
164    ];
165
166    if app.check_results.is_empty() {
167        let msg = if app.checks_loading {
168            "    Loading..."
169        } else {
170            "    Checks not yet run"
171        };
172        lines.push(Line::from(Span::styled(msg, dim)));
173    } else {
174        for check in &app.check_results {
175            let (marker, marker_style) = if check.passed {
176                ("\u{2713}", pass_style)
177            } else {
178                ("\u{2717}", fail_style)
179            };
180            let mut spans = vec![
181                Span::styled("    ", dim),
182                Span::styled(marker.to_string(), marker_style),
183                Span::styled(format!("  {:<14}", check.name), dim),
184                Span::styled(&check.message, dim),
185            ];
186            if !check.passed && check.critical {
187                spans.push(Span::styled("  (critical)", fail_style));
188            }
189            lines.push(Line::from(spans));
190            if !check.passed {
191                if let Some(suggestion) = &check.suggestion {
192                    lines.push(Line::from(vec![
193                        Span::styled("      ", dim),
194                        Span::styled(suggestion, dim),
195                    ]));
196                }
197            }
198        }
199    }
200
201    let content = Paragraph::new(lines).block(
202        Block::default()
203            .borders(Borders::ALL)
204            .border_style(Style::default().fg(Color::DarkGray)),
205    );
206    frame.render_widget(content, area);
207}
208
209fn render_options_detail(app: &App, frame: &mut Frame, area: Rect) {
210    let dim = Style::default().fg(Color::DarkGray);
211    let key_style = Style::default()
212        .fg(Color::Rgb(37, 99, 235))
213        .add_modifier(Modifier::BOLD);
214    let section_style = Style::default()
215        .fg(Color::White)
216        .add_modifier(Modifier::BOLD);
217    let active_style = Style::default()
218        .fg(Color::Rgb(21, 128, 61))
219        .add_modifier(Modifier::BOLD);
220
221    let config_path = crate::config::Config::default_path()
222        .ok()
223        .and_then(|p| p.parent().map(|parent| parent.display().to_string()))
224        .unwrap_or_else(|| "~/.config/git-same".to_string());
225
226    // Dry run toggle
227    let (dry_yes, dry_no) = if app.dry_run {
228        (active_style, dim)
229    } else {
230        (dim, active_style)
231    };
232
233    // Mode toggle
234    let (mode_fetch, mode_pull) = if app.sync_pull {
235        (dim, active_style)
236    } else {
237        (active_style, dim)
238    };
239
240    let lines = vec![
241        Line::from(""),
242        Line::from(Span::styled("  Global Config", section_style)),
243        Line::from(""),
244        Line::from(vec![Span::styled(
245            format!("    Concurrency: {}", app.config.concurrency),
246            dim,
247        )]),
248        Line::from(""),
249        Line::from(Span::styled("  Options", section_style)),
250        Line::from(""),
251        Line::from(vec![
252            Span::styled("    ", dim),
253            Span::styled("[d]", key_style),
254            Span::styled("  Dry run:  ", dim),
255            Span::styled("Yes", dry_yes),
256            Span::styled(" / ", dim),
257            Span::styled("No", dry_no),
258        ]),
259        Line::from(vec![
260            Span::styled("    ", dim),
261            Span::styled("[m]", key_style),
262            Span::styled("  Mode:     ", dim),
263            Span::styled("Fetch", mode_fetch),
264            Span::styled(" / ", dim),
265            Span::styled("Pull", mode_pull),
266        ]),
267        Line::from(""),
268        Line::from(Span::styled("  Folders", section_style)),
269        Line::from(""),
270        Line::from(vec![
271            Span::styled("    ", dim),
272            Span::styled("[c]", key_style),
273            Span::styled("  Config folder", dim),
274            Span::styled(format!("  \u{2014} {}", config_path), dim),
275        ]),
276    ];
277
278    let content = Paragraph::new(lines).block(
279        Block::default()
280            .borders(Borders::ALL)
281            .border_style(Style::default().fg(Color::DarkGray)),
282    );
283    frame.render_widget(content, area);
284}
285
286fn render_bottom_actions(app: &App, frame: &mut Frame, area: Rect) {
287    let rows = Layout::vertical([
288        Constraint::Length(1), // Actions
289        Constraint::Length(1), // Navigation
290    ])
291    .split(area);
292
293    let dim = Style::default().fg(Color::DarkGray);
294    let key_style = Style::default()
295        .fg(Color::Rgb(37, 99, 235))
296        .add_modifier(Modifier::BOLD);
297
298    // Line 1: Context-sensitive actions (centered)
299    let mut action_spans = vec![];
300    if app.settings_index == 1 {
301        action_spans.extend([
302            Span::raw(" "),
303            Span::styled("[c]", key_style),
304            Span::styled(" Config", dim),
305            Span::raw("   "),
306            Span::styled("[d]", key_style),
307            Span::styled(" Dry-run", dim),
308            Span::raw("   "),
309            Span::styled("[m]", key_style),
310            Span::styled(" Mode", dim),
311        ]);
312    }
313    let actions = Paragraph::new(vec![Line::from(action_spans)]).centered();
314
315    // Line 2: Navigation — left (quit, back) and right (arrows)
316    let nav_cols =
317        Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(rows[1]);
318
319    let left_spans = vec![
320        Span::raw(" "),
321        Span::styled("[q]", key_style),
322        Span::styled(" Quit", dim),
323        Span::raw("   "),
324        Span::styled("[Esc]", key_style),
325        Span::styled(" Back", dim),
326    ];
327
328    let right_spans = vec![
329        Span::styled("[Tab]", key_style),
330        Span::styled(" Switch", dim),
331        Span::raw("   "),
332        Span::styled("[\u{2191}]", key_style),
333        Span::raw(" "),
334        Span::styled("[\u{2193}]", key_style),
335        Span::styled(" Move", dim),
336        Span::raw(" "),
337    ];
338
339    frame.render_widget(actions, rows[0]);
340    frame.render_widget(Paragraph::new(vec![Line::from(left_spans)]), nav_cols[0]);
341    frame.render_widget(
342        Paragraph::new(vec![Line::from(right_spans)]).right_aligned(),
343        nav_cols[1],
344    );
345}
346
347#[cfg(test)]
348#[path = "settings_tests.rs"]
349mod tests;