use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
};
use crate::tui::app::App;
use super::super::ui::{C_WHITE, C_SUBTLE, C_DIM, C_CYAN, C_YELLOW, C_GREEN, C_RED};
pub fn render(f: &mut Frame, app: &App, area: Rect) {
let bc = app.brand_color();
let focused = !app.sidebar_focused;
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(area);
let remotes = &app.remote_view.remotes;
let mirrors = &app.remote_view.mirrors;
let mut items: Vec<ListItem> = vec![];
let mut sel_list_pos = 0usize;
if !remotes.is_empty() {
items.push(ListItem::new(Line::from(vec![
Span::styled(" git remotes ", Style::default().fg(C_SUBTLE).add_modifier(Modifier::BOLD)),
])));
for (i, r) in remotes.iter().enumerate() {
let is_sel = i == app.remote_view.idx;
if is_sel { sel_list_pos = items.len(); }
let style = if is_sel {
Style::default().bg(app.selected_bg()).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let prefix = if is_sel { "█ " } else { " " };
items.push(ListItem::new(Line::from(vec![
Span::styled(prefix, Style::default().fg(bc)),
Span::styled(format!("{:<12} ", &r.name), Style::default().fg(C_WHITE).add_modifier(Modifier::BOLD)),
Span::styled(format!("{:<10}", &r.platform), Style::default().fg(platform_color(&r.platform))),
Span::styled(truncate(&r.url, 30), Style::default().fg(C_DIM)),
])).style(style));
}
}
if !mirrors.is_empty() {
if !remotes.is_empty() {
items.push(ListItem::new(Line::from(Span::raw(" "))));
}
items.push(ListItem::new(Line::from(vec![
Span::styled(" mirrors ", Style::default().fg(C_SUBTLE).add_modifier(Modifier::BOLD)),
])));
for (i, m) in mirrors.iter().enumerate() {
let abs_idx = remotes.len() + i;
let is_sel = abs_idx == app.remote_view.idx;
if is_sel { sel_list_pos = items.len(); }
let style = if is_sel {
Style::default().bg(app.selected_bg()).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let prefix = if is_sel { "█ " } else { " " };
let kind_color = if m.kind == "primary" { C_YELLOW } else { C_SUBTLE };
items.push(ListItem::new(Line::from(vec![
Span::styled(prefix, Style::default().fg(bc)),
Span::styled(format!("{:<12} ", &m.name), Style::default().fg(C_WHITE).add_modifier(Modifier::BOLD)),
Span::styled(format!("{:<9}", &m.kind), Style::default().fg(kind_color)),
Span::styled(format!(" {:<10}", &m.platform), Style::default().fg(platform_color(&m.platform))),
])).style(style));
}
}
if remotes.is_empty() && mirrors.is_empty() {
items.push(ListItem::new(Span::styled(
" no remotes configured",
Style::default().fg(C_DIM),
)));
}
let total = remotes.len() + mirrors.len();
let title = format!(" remote — {} git {} mirrors ", remotes.len(), mirrors.len());
let mut state = ListState::default();
if total > 0 { state.select(Some(sel_list_pos)); }
let list_block = Block::default()
.title(Span::styled(title,
if focused { Style::default().fg(C_WHITE).add_modifier(Modifier::BOLD) }
else { Style::default().fg(bc) },
))
.borders(Borders::ALL).border_type(app.border_type())
.border_style(if focused { Style::default().fg(C_WHITE) } else { Style::default().fg(bc) });
f.render_stateful_widget(List::new(items).block(list_block), chunks[0], &mut state);
let info_lines: Vec<Line> = if app.remote_view.selected_is_mirror() {
if let Some(m) = app.remote_view.selected_mirror() {
let https_url = ssh_to_https(&m.url);
vec![
Line::from(vec![
Span::styled(" name ", Style::default().fg(C_SUBTLE)),
Span::styled(&m.name, Style::default().fg(C_WHITE).add_modifier(Modifier::BOLD)),
]),
Line::from(vec![
Span::styled(" kind ", Style::default().fg(C_SUBTLE)),
Span::styled(&m.kind, Style::default().fg(
if m.kind == "primary" { C_YELLOW } else { C_SUBTLE }
).add_modifier(Modifier::BOLD)),
]),
Line::from(vec![
Span::styled(" platform ", Style::default().fg(C_SUBTLE)),
Span::styled(&m.platform, Style::default().fg(platform_color(&m.platform))),
]),
Line::from(vec![
Span::styled(" account ", Style::default().fg(C_SUBTLE)),
Span::styled(&m.account, Style::default().fg(C_WHITE)),
]),
Line::from(vec![
Span::styled(" repo ", Style::default().fg(C_SUBTLE)),
Span::styled(&m.repo, Style::default().fg(C_CYAN)),
]),
Line::from(vec![
Span::styled(" url ", Style::default().fg(C_SUBTLE)),
Span::styled(&m.url, Style::default().fg(C_DIM)),
]),
Line::from(vec![
Span::styled(" https ", Style::default().fg(C_SUBTLE)),
Span::styled(https_url, Style::default().fg(C_DIM)),
]),
]
} else {
vec![Line::from(Span::styled(" no mirror selected", Style::default().fg(C_DIM)))]
}
} else if let Some(r) = app.remote_view.selected_remote() {
let https_url = ssh_to_https(&r.url);
vec![
Line::from(vec![
Span::styled(" name ", Style::default().fg(C_SUBTLE)),
Span::styled(&r.name, Style::default().fg(C_WHITE).add_modifier(Modifier::BOLD)),
]),
Line::from(vec![
Span::styled(" platform ", Style::default().fg(C_SUBTLE)),
Span::styled(&r.platform, Style::default().fg(platform_color(&r.platform))),
]),
Line::from(vec![
Span::styled(" url ", Style::default().fg(C_SUBTLE)),
Span::styled(&r.url, Style::default().fg(C_CYAN)),
]),
Line::from(vec![
Span::styled(" https ", Style::default().fg(C_SUBTLE)),
Span::styled(https_url, Style::default().fg(C_DIM)),
]),
]
} else {
vec![Line::from(Span::styled(" no remote selected", Style::default().fg(C_DIM)))]
};
let info_block = Block::default()
.title(Span::styled(" info ", Style::default().fg(bc)))
.borders(Borders::ALL).border_type(app.border_type())
.border_style(Style::default().fg(bc));
f.render_widget(Paragraph::new(info_lines).block(info_block), chunks[1]);
if app.remote_view.ops_mode {
let is_mirror = app.remote_view.selected_is_mirror();
let (ops, dropdown_w): (&[(&str, bool)], u16) = if is_mirror {
(&[
("sync all", false),
("force sync", false),
("add mirror", false),
("set primary", false),
("rename", false),
("remove ⚠", true),
], 22)
} else {
(&[
("fetch", false),
("add remote", false),
("rename", false),
("remove ⚠", true),
("open in browser", false),
], 22)
};
let dropdown_h = ops.len() as u16 + 2;
let entry_y = chunks[0].y + 1 + sel_list_pos as u16 + 1;
let drop_y = if entry_y + dropdown_h < chunks[0].y + chunks[0].height {
entry_y
} else {
chunks[0].y + chunks[0].height - dropdown_h
};
let drop_area = Rect::new(chunks[0].x + 3, drop_y, dropdown_w, dropdown_h);
let drop_items: Vec<ListItem> = ops.iter().enumerate().map(|(i, (label, danger))| {
let is_sel = i == app.remote_view.ops_idx;
let color = if *danger { C_RED } else if is_sel { C_WHITE } else { C_SUBTLE };
let prefix = if is_sel { "▶ " } else { " " };
let style = if is_sel {
Style::default().bg(app.selected_bg()).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(Line::from(vec![
Span::styled(prefix, Style::default().fg(bc)),
Span::styled(*label, Style::default().fg(color)),
])).style(style)
}).collect();
let mut drop_state = ListState::default();
drop_state.select(Some(app.remote_view.ops_idx));
let drop_block = Block::default()
.borders(Borders::ALL).border_type(app.border_type())
.border_style(Style::default().fg(bc));
f.render_widget(Clear, drop_area);
f.render_stateful_widget(List::new(drop_items).block(drop_block), drop_area, &mut drop_state);
}
}
fn platform_color(platform: &str) -> ratatui::style::Color {
match platform.to_lowercase().as_str() {
"github" => C_WHITE,
"gitlab" => C_YELLOW,
"bitbucket" => C_CYAN,
"codeberg" => C_GREEN,
_ => C_DIM,
}
}
fn ssh_to_https(url: &str) -> String {
if let Some(rest) = url.strip_prefix("git@") {
let s = rest.replacen(':', "/", 1);
let s = s.strip_suffix(".git").unwrap_or(&s);
return format!("https://{}", s);
}
url.strip_suffix(".git").unwrap_or(url).to_string()
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max { return s.to_string(); }
let cut: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{}…", cut)
}