use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState, Wrap};
use time::OffsetDateTime;
use crate::domain::project::ProjectListItem;
use crate::ui::state::help_action;
use crate::ui::{Page, layout, style};
const ROW_HIGHLIGHT_SYMBOL: &str = "";
const ACTIVE_PROJECT_MARKER: &str = "* ";
const TABLE_COLUMN_SPACING: u16 = 2;
const AGENTTY_INFO_PANEL_HEIGHT: u16 = 9;
const AGENTTY_INFO_ASCII_WIDTH_PERCENT: u16 = 58;
const AGENTTY_ASCII_ART_LINE_COUNT: u16 = 5;
const AGENTTY_ASCII_ART_WIDTH: u16 = 45;
const AGENTTY_VERSION: &str = concat!("v", env!("CARGO_PKG_VERSION"));
const AGENTTY_SHORT_DESCRIPTION: &str = "Agentty is an ADE (Agentic Development Environment) for \
structured, controllable AI-assisted software \
development.";
const AGENTTY_ASCII_ART: &str = r" _ ____ _____ _ _ _____ _____ __ __
/ \ / ___| ____| \ | |_ _|_ _|\ \ / /
/ _ \| | _| _| | \| | | | | | \ V /
/ ___ \ |_| | |___| |\ | | | | | | |
/_/ \_\____|_____|_| \_| |_| |_| |_|";
pub struct ProjectListPage<'a> {
pub active_project_id: i64,
pub projects: &'a [ProjectListItem],
pub table_state: &'a mut TableState,
}
impl<'a> ProjectListPage<'a> {
pub fn new(
projects: &'a [ProjectListItem],
table_state: &'a mut TableState,
active_project_id: i64,
) -> Self {
Self {
active_project_id,
projects,
table_state,
}
}
}
impl Page for ProjectListPage<'_> {
fn render(&mut self, f: &mut Frame, area: Rect) {
let chunks = Layout::default()
.constraints([Constraint::Min(0), Constraint::Length(1)])
.margin(1)
.split(area);
let main_area = chunks[0];
let footer_area = chunks[1];
let content_chunks = Layout::vertical([
Constraint::Length(AGENTTY_INFO_PANEL_HEIGHT),
Constraint::Min(0),
])
.split(main_area);
let info_area = content_chunks[0];
let project_area = content_chunks[1];
let info_panel_block = Block::default()
.borders(Borders::ALL)
.title("Agentty")
.border_style(style::border_style());
let info_panel_inner_area = info_panel_block.inner(info_area);
let info_panel_chunks = Layout::horizontal([
Constraint::Percentage(AGENTTY_INFO_ASCII_WIDTH_PERCENT),
Constraint::Percentage(100 - AGENTTY_INFO_ASCII_WIDTH_PERCENT),
])
.split(info_panel_inner_area);
let logo_area = info_panel_chunks[0];
let details_area = info_panel_chunks[1];
let centered_logo_area = layout::centered_content_rect(
logo_area,
AGENTTY_ASCII_ART_WIDTH,
AGENTTY_ASCII_ART_LINE_COUNT,
);
let logo_panel = Paragraph::new(AGENTTY_ASCII_ART)
.style(Style::default().fg(style::palette::text()))
.wrap(Wrap { trim: false });
let details_panel = Paragraph::new(agentty_info_details_text())
.style(Style::default().fg(style::palette::text()))
.wrap(Wrap { trim: true });
let selected_style = Style::default().bg(style::palette::surface());
let header = Row::new(["Project", "Branch", "Sessions", "Last Opened", "Path"])
.style(
Style::default()
.bg(style::palette::surface())
.fg(style::palette::text_muted())
.add_modifier(Modifier::BOLD),
)
.height(1)
.bottom_margin(1);
let active_project_id = self.active_project_id;
let rows = self
.projects
.iter()
.map(|project_item| render_project_row(project_item, active_project_id));
let table = Table::new(
rows,
[
Constraint::Length(20),
Constraint::Length(12),
Constraint::Length(8),
Constraint::Length(12),
Constraint::Fill(1),
],
)
.column_spacing(TABLE_COLUMN_SPACING)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.title("Projects")
.border_style(style::border_style()),
)
.row_highlight_style(selected_style)
.highlight_symbol(ROW_HIGHLIGHT_SYMBOL);
f.render_stateful_widget(table, project_area, self.table_state);
f.render_widget(info_panel_block, info_area);
f.render_widget(logo_panel, centered_logo_area);
f.render_widget(details_panel, details_area);
let help_message = Paragraph::new(project_list_footer_line());
f.render_widget(help_message, footer_area);
}
}
fn render_project_row(project_item: &ProjectListItem, active_project_id: i64) -> Row<'static> {
let (title, branch, last_opened, path) = project_row_values(project_item, active_project_id);
Row::new(vec![
Cell::from(title),
Cell::from(branch),
Cell::from(session_count_line(
project_item.session_count,
project_item.active_session_count,
)),
Cell::from(last_opened),
Cell::from(path),
])
.style(project_row_style(project_item, active_project_id))
}
fn agentty_info_details_text() -> String {
format!(
"Version: {AGENTTY_VERSION}\n\n{AGENTTY_SHORT_DESCRIPTION}\n\nDocs: https://agentty.xyz/docs"
)
}
fn project_list_footer_line() -> Line<'static> {
help_action::footer_line(&help_action::project_list_footer_actions())
}
fn project_row_values(
project_item: &ProjectListItem,
active_project_id: i64,
) -> (String, String, String, String) {
let project = &project_item.project;
let title = project_title(project_item, active_project_id);
let branch = project.git_branch.as_deref().unwrap_or("-");
let last_opened = format_last_opened(project.last_opened_at);
let path = project.path.to_string_lossy().to_string();
(title, branch.to_string(), last_opened, path)
}
fn project_row_style(project_item: &ProjectListItem, active_project_id: i64) -> Style {
if project_item.project.id == active_project_id {
return Style::default().fg(style::palette::accent_soft());
}
Style::default().fg(style::palette::text())
}
fn project_title(project_item: &ProjectListItem, active_project_id: i64) -> String {
let display_label = project_item.project.display_label();
if project_item.project.id == active_project_id {
return format!("{ACTIVE_PROJECT_MARKER}{display_label}");
}
display_label
}
fn session_count_line(total: u32, active: u32) -> Line<'static> {
if active > 0 {
return Line::from(vec![
Span::raw(format!("{total} ")),
Span::styled(
format!("â–¶ {active}"),
Style::default().fg(style::palette::warning()),
),
]);
}
Line::from(total.to_string())
}
fn format_last_opened(last_opened_at: Option<i64>) -> String {
let Some(last_opened_at) = last_opened_at else {
return "Never".to_string();
};
let Ok(last_opened_datetime) = OffsetDateTime::from_unix_timestamp(last_opened_at) else {
return "Unknown".to_string();
};
let year = last_opened_datetime.year();
let month = u8::from(last_opened_datetime.month());
let day = last_opened_datetime.day();
format!("{year:04}-{month:02}-{day:02}")
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::domain::project::Project;
use crate::domain::theme::ColorTheme;
#[test]
fn test_row_highlight_symbol_uses_background_only_selection() {
let highlight_symbol = ROW_HIGHLIGHT_SYMBOL;
let is_empty_symbol = highlight_symbol.is_empty();
assert!(is_empty_symbol);
}
#[test]
fn test_project_table_column_spacing_is_wider_for_readability() {
let expected_spacing = 2;
let spacing = TABLE_COLUMN_SPACING;
assert_eq!(spacing, expected_spacing);
}
#[test]
fn test_render_uses_palette_border_for_projects_table() {
let _theme_scope = style::scoped_active_theme(ColorTheme::Current);
let projects = vec![ProjectListItem {
active_session_count: 0,
last_session_updated_at: None,
project: Project {
created_at: 1,
display_name: Some("agentty".to_string()),
git_branch: Some("main".to_string()),
id: 42,
is_favorite: false,
last_opened_at: None,
path: PathBuf::from("/tmp/agentty"),
updated_at: 2,
},
session_count: 0,
}];
let mut table_state = TableState::default();
table_state.select(Some(0));
let backend = ratatui::backend::TestBackend::new(100, 30);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
terminal
.draw(|frame| {
ProjectListPage::new(&projects, &mut table_state, 42).render(frame, frame.area());
})
.expect("failed to draw projects page");
let border_cell_count = foreground_symbol_cell_count(terminal.backend().buffer(), "┌");
assert!(
border_cell_count >= 2,
"expected both project page panels to use palette border color"
);
}
#[test]
fn test_format_last_opened_uses_iso_like_date() {
let last_opened_at = Some(1_700_000_000);
let formatted = format_last_opened(last_opened_at);
assert_eq!(formatted, "2023-11-14");
}
#[test]
fn test_format_last_opened_returns_never_without_timestamp() {
let last_opened_at = None;
let formatted = format_last_opened(last_opened_at);
assert_eq!(formatted, "Never");
}
#[test]
fn test_format_last_opened_returns_unknown_for_invalid_timestamp() {
let last_opened_at = Some(i64::MAX);
let formatted = format_last_opened(last_opened_at);
assert_eq!(formatted, "Unknown");
}
#[test]
fn test_project_row_values_show_metadata() {
let project_item = ProjectListItem {
active_session_count: 0,
last_session_updated_at: Some(20),
project: Project {
created_at: 1,
display_name: Some("agentty".to_string()),
git_branch: Some("main".to_string()),
id: 1,
is_favorite: true,
last_opened_at: Some(1_700_000_000),
path: PathBuf::from("/tmp/agentty"),
updated_at: 2,
},
session_count: 3,
};
let values = project_row_values(&project_item, 99);
assert_eq!(values.0, "agentty");
assert_eq!(values.1, "main");
assert_eq!(values.2, "2023-11-14");
assert_eq!(values.3, "/tmp/agentty");
}
#[test]
fn test_project_row_values_use_fallbacks_for_missing_branch_and_timestamp() {
let project_item = ProjectListItem {
active_session_count: 0,
last_session_updated_at: None,
project: Project {
created_at: 1,
display_name: None,
git_branch: None,
id: 1,
is_favorite: false,
last_opened_at: None,
path: PathBuf::from("/tmp/agentty"),
updated_at: 2,
},
session_count: 0,
};
let values = project_row_values(&project_item, 99);
assert_eq!(values.0, "agentty");
assert_eq!(values.1, "-");
assert_eq!(values.2, "Never");
}
#[test]
fn test_session_count_line_shows_plain_total_without_active() {
let line = session_count_line(7, 0);
assert_eq!(line.to_string(), "7");
assert_eq!(line.spans.len(), 1);
}
#[test]
fn test_session_count_line_colors_active_indicator_yellow() {
let line = session_count_line(5, 2);
assert_eq!(line.spans.len(), 2);
assert_eq!(line.spans[0].content.as_ref(), "5 ");
assert_eq!(line.spans[1].content.as_ref(), "â–¶ 2");
assert_eq!(line.spans[1].style.fg, Some(style::palette::warning()));
}
#[test]
fn test_project_row_values_mark_active_project_title() {
let project_item = ProjectListItem {
active_session_count: 0,
last_session_updated_at: Some(20),
project: Project {
created_at: 1,
display_name: Some("agentty".to_string()),
git_branch: Some("main".to_string()),
id: 42,
is_favorite: true,
last_opened_at: Some(1_700_000_000),
path: PathBuf::from("/tmp/agentty"),
updated_at: 2,
},
session_count: 3,
};
let values = project_row_values(&project_item, 42);
assert_eq!(values.0, "* agentty");
}
#[test]
fn test_project_row_style_uses_accent_for_active_project() {
let project_item = ProjectListItem {
active_session_count: 0,
last_session_updated_at: None,
project: Project {
created_at: 1,
display_name: Some("agentty".to_string()),
git_branch: Some("main".to_string()),
id: 42,
is_favorite: false,
last_opened_at: None,
path: PathBuf::from("/tmp/agentty"),
updated_at: 2,
},
session_count: 0,
};
let style = project_row_style(&project_item, 42);
assert_eq!(style.fg, Some(style::palette::accent_soft()));
}
#[test]
fn test_project_row_style_uses_text_color_for_inactive_project() {
let project_item = ProjectListItem {
active_session_count: 0,
last_session_updated_at: None,
project: Project {
created_at: 1,
display_name: Some("agentty".to_string()),
git_branch: Some("main".to_string()),
id: 42,
is_favorite: false,
last_opened_at: None,
path: PathBuf::from("/tmp/agentty"),
updated_at: 2,
},
session_count: 0,
};
let style = project_row_style(&project_item, 7);
assert_eq!(style.fg, Some(style::palette::text()));
}
#[test]
fn test_project_list_footer_line_matches_project_shortcuts() {
let expected_line = help_action::footer_line(&help_action::project_list_footer_actions());
let footer_line = project_list_footer_line();
assert_eq!(footer_line, expected_line);
}
#[test]
fn test_agentty_info_details_text_includes_version_and_description() {
let expected_version = AGENTTY_VERSION;
let expected_description = AGENTTY_SHORT_DESCRIPTION;
let info_text = agentty_info_details_text();
assert!(info_text.contains(expected_version));
assert!(info_text.contains(expected_description));
}
#[test]
fn test_agentty_ascii_art_banner_matches_reference_header() {
let expected_banner_header = " _ ____ _____ _ _ _____ _____ __ __";
assert!(AGENTTY_ASCII_ART.starts_with(expected_banner_header));
}
#[test]
fn test_agentty_ascii_art_line_count_matches_banner() {
let actual_line_count = AGENTTY_ASCII_ART.lines().count();
assert_eq!(actual_line_count, usize::from(AGENTTY_ASCII_ART_LINE_COUNT));
}
#[test]
fn test_agentty_ascii_art_width_matches_banner() {
let actual_width = AGENTTY_ASCII_ART
.lines()
.map(str::len)
.max()
.unwrap_or_default();
assert_eq!(actual_width, usize::from(AGENTTY_ASCII_ART_WIDTH));
}
fn foreground_symbol_cell_count(buffer: &ratatui::buffer::Buffer, symbol: &str) -> usize {
buffer
.content()
.iter()
.filter(|cell| cell.symbol() == symbol && cell.fg == style::palette::border())
.count()
}
}