use ratatui::{
Frame,
layout::{Constraint, Flex, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Wrap},
};
use crate::app::App;
const MAX_HALF_LEN: usize = 100;
pub fn is_valid_repo_slug(s: &str) -> bool {
let mut parts = s.splitn(3, '/');
let owner = match parts.next() {
Some(o) if !o.is_empty() => o,
_ => return false,
};
let name = match parts.next() {
Some(n) if !n.is_empty() => n,
_ => return false,
};
if parts.next().is_some() {
return false;
}
if owner.len() > MAX_HALF_LEN || name.len() > MAX_HALF_LEN {
return false;
}
if owner == "." || owner == ".." || name == "." || name == ".." {
return false;
}
let is_allowed = |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_');
owner.chars().all(is_allowed) && name.chars().all(is_allowed)
}
pub fn draw(f: &mut Frame, app: &App) {
let p = &app.palette;
let area = picker_rect(f.area());
let block = Block::default()
.title(" Repositories ")
.borders(Borders::ALL)
.border_style(Style::default().fg(p.border_focused))
.style(Style::default().bg(p.help_bg));
f.render_widget(Clear, area);
f.render_widget(block, area);
let inner = inner_area(area);
let input_height: u16 = 3;
let list_height = inner.height.saturating_sub(input_height);
let [list_area, input_area] =
Layout::vertical([Constraint::Length(list_height), Constraint::Length(input_height)])
.areas(inner);
render_list(f, app, list_area);
render_input(f, app, input_area);
}
fn render_list(f: &mut Frame, app: &App, area: Rect) {
let p = &app.palette;
if app.config.repos.is_empty() {
let hint = Paragraph::new(Line::from(Span::styled(
"No repositories tracked yet. Press `a` to add one.",
Style::default().fg(p.dim),
)))
.wrap(Wrap { trim: false });
f.render_widget(hint, area);
return;
}
let visible_rows = area.height as usize;
let total = app.config.repos.len();
let cursor = app.repo_picker_list_cursor.min(total.saturating_sub(1));
let scroll_offset = if visible_rows == 0 {
0
} else {
cursor.saturating_sub(visible_rows - 1).min(total.saturating_sub(visible_rows))
};
let lines: Vec<Line> = app
.config
.repos
.iter()
.enumerate()
.skip(scroll_offset)
.take(visible_rows)
.map(|(idx, repo)| {
let selected = idx == cursor;
let bullet = if selected { ">" } else { " " };
let style = if selected {
Style::default().fg(p.selection_fg).bg(p.selection_bg).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(p.foreground)
};
Line::from(Span::styled(format!(" {bullet} {repo}"), style))
})
.collect();
let paragraph = Paragraph::new(lines);
f.render_widget(paragraph, area);
}
fn render_input(f: &mut Frame, app: &App, area: Rect) {
let p = &app.palette;
let is_input_mode = app.repo_picker_mode == crate::app::RepoPickerMode::Input;
let label_style = if is_input_mode {
Style::default().fg(p.accent).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(p.dim)
};
let cursor_char = if is_input_mode { "_" } else { "" };
let input_text = format!("{}{}", app.repo_picker_input, cursor_char);
let border_style = if is_input_mode {
Style::default().fg(p.border_focused)
} else {
Style::default().fg(p.border)
};
let block = Block::default()
.title(Span::styled(" Add (owner/name): ", label_style))
.borders(Borders::TOP)
.border_style(border_style);
let paragraph = Paragraph::new(Line::from(Span::styled(
format!(" {input_text}"),
Style::default().fg(p.foreground),
)))
.block(block);
f.render_widget(paragraph, area);
}
fn picker_rect(area: Rect) -> Rect {
let width = 62u16.min(area.width);
let height = 20u16.min(area.height.saturating_sub(4)).max(8);
let [_, center_v, _] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(height), Constraint::Fill(1)])
.flex(Flex::Center)
.areas(area);
let [_, center_h, _] =
Layout::horizontal([Constraint::Fill(1), Constraint::Length(width), Constraint::Fill(1)])
.flex(Flex::Center)
.areas(center_v);
center_h
}
fn inner_area(area: Rect) -> Rect {
Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_slugs_accepted() {
assert!(is_valid_repo_slug("rust-lang/rust"));
assert!(is_valid_repo_slug("owner/repo"));
assert!(is_valid_repo_slug("my-org/my.repo_name-123"));
assert!(is_valid_repo_slug("a/b"));
assert!(is_valid_repo_slug("A/B")); }
#[test]
fn empty_slug_rejected() {
assert!(!is_valid_repo_slug(""));
}
#[test]
fn no_slash_rejected() {
assert!(!is_valid_repo_slug("no-slash"));
}
#[test]
fn two_slashes_rejected() {
assert!(!is_valid_repo_slug("owner/sub/repo"));
assert!(!is_valid_repo_slug("owner//repo"));
}
#[test]
fn empty_owner_rejected() {
assert!(!is_valid_repo_slug("/name"));
}
#[test]
fn empty_name_rejected() {
assert!(!is_valid_repo_slug("owner/"));
}
#[test]
fn bad_chars_rejected() {
assert!(!is_valid_repo_slug("owner/repo name")); assert!(!is_valid_repo_slug("owner/repo!")); assert!(!is_valid_repo_slug("owner@org/repo")); }
#[test]
fn too_long_owner_rejected() {
let long = "a".repeat(101);
assert!(!is_valid_repo_slug(&format!("{long}/repo")));
}
#[test]
fn too_long_name_rejected() {
let long = "a".repeat(101);
assert!(!is_valid_repo_slug(&format!("owner/{long}")));
}
#[test]
fn exactly_max_len_accepted() {
let exactly = "a".repeat(100);
assert!(is_valid_repo_slug(&format!("{exactly}/{exactly}")));
}
#[test]
fn dot_and_dotdot_rejected() {
assert!(!is_valid_repo_slug("owner/."));
assert!(!is_valid_repo_slug("owner/.."));
assert!(!is_valid_repo_slug("./repo"));
assert!(!is_valid_repo_slug("../repo"));
}
use ratatui::Terminal;
use ratatui::backend::TestBackend;
#[test]
fn draw_empty_state_shows_hint() {
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("test terminal");
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = crate::app::App::new(config, session);
app.focus = crate::app::Focus::RepoPicker;
terminal.draw(|f| draw(f, &app)).expect("draw");
let buffer = terminal.backend().buffer();
let rendered: String = buffer.content.iter().map(ratatui::buffer::Cell::symbol).collect();
assert!(rendered.contains("Repositories"), "overlay must render the `Repositories` title");
assert!(
rendered.contains("No repositories tracked yet"),
"empty state hint must be visible; got: {rendered}"
);
}
#[test]
fn draw_populated_list_shows_repo() {
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("test terminal");
let config = crate::config::Config {
repos: vec!["rust-lang/rust".to_owned()],
..Default::default()
};
let session = crate::state::AppSession::default();
let mut app = crate::app::App::new(config, session);
app.focus = crate::app::Focus::RepoPicker;
terminal.draw(|f| draw(f, &app)).expect("draw");
let buffer = terminal.backend().buffer();
let rendered: String = buffer.content.iter().map(ratatui::buffer::Cell::symbol).collect();
assert!(rendered.contains("rust-lang/rust"), "configured repo must render");
}
}