use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use chrono::{DateTime, Local};
use ratada::text::truncate;
use ratatui::Frame;
use ratatui::layout::{Constraint, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Cell, Row, Table, TableState};
use unicode_width::UnicodeWidthStr;
use crate::config::{ColumnWidth, Config};
use crate::domain::filter::Tab;
use crate::domain::repo::{GitInfo, Repo, RepoKind, is_dir_target};
use crate::domain::stats::{CodeEntry, GitStats};
use crate::theme::Skin;
use crate::tui::columns::{
CellSource, ColumnSet, StatColumn, cell_text, stat_columns,
};
use crate::tui::presentation::{
IconSet, highlight_name, name_plain, slug_style, status_text,
};
use crate::tui::skin::Colors;
pub struct TableView<'a> {
pub tab: Tab,
pub config: &'a Config,
pub skin: &'a Skin,
pub colors: &'a Colors,
pub icons: &'a IconSet,
pub example_mode: bool,
pub spinner: Option<(&'a HashSet<PathBuf>, &'a str)>,
pub selected: &'a [bool],
pub has_selection: bool,
pub missing: &'a HashSet<PathBuf>,
pub show_slugs: bool,
pub query: Option<&'a str>,
pub zip_backups: &'a HashMap<PathBuf, DateTime<Local>>,
pub offset: &'a std::cell::Cell<usize>,
pub columns: ColumnSet,
pub code: &'a HashMap<PathBuf, CodeEntry>,
pub git: &'a HashMap<PathBuf, GitStats>,
pub computing: &'a HashSet<PathBuf>,
pub now: i64,
}
impl TableView<'_> {
fn cell_source<'b>(&'b self, repo: &Repo) -> CellSource<'b> {
CellSource {
code: self.code.get(&repo.path),
git: self.git.get(&repo.path),
open_count: repo.open_count,
last_used: repo.last_used,
pending: self.computing.contains(&repo.path),
now: self.now,
}
}
fn stat_text(&self, repo: &Repo, column: StatColumn) -> String {
match cell_text(column, self.cell_source(repo)) {
Some(text) => text,
None => self.spinner_frame().to_string(),
}
}
fn spinner_frame(&self) -> &str {
self.spinner.map_or("-", |(_, glyph)| glyph)
}
}
fn settled_offset(
saved: usize,
cursor: usize,
row_count: usize,
viewport: usize,
) -> usize {
let mut offset = saved.min(row_count.saturating_sub(1));
if cursor < offset {
offset = cursor;
} else if viewport > 0 && cursor >= offset + viewport {
offset = cursor + 1 - viewport;
}
offset
}
fn shown_slug<'a>(repo: &'a Repo, view: &TableView) -> Option<&'a str> {
repo.slug.as_deref().filter(|_| view.show_slugs)
}
fn name_cell_spans(repo: &Repo, view: &TableView) -> Vec<Span<'static>> {
let name = repo.display_name();
let mut spans = match view.query {
Some(query) if !query.trim().is_empty() => {
highlight_name(&name, query, view.colors)
}
_ => vec![Span::raw(name)],
};
if let Some(slug) = shown_slug(repo, view) {
spans.push(Span::styled(format!(" {slug}"), slug_style(view.colors)));
}
spans
}
fn effective_info(repo: &Repo, example_mode: bool) -> Option<&GitInfo> {
if example_mode {
repo.example_git_info.as_ref()
} else {
repo.git_info.as_ref()
}
}
pub fn render_table(
frame: &mut Frame,
area: Rect,
repos: &[&Repo],
cursor: usize,
view: &TableView,
) {
let viewport = area.height.saturating_sub(1) as usize; let overflow = viewport > 0 && repos.len() > viewport;
let table_area = if overflow {
Rect {
width: area.width.saturating_sub(1),
..area
}
} else {
area
};
let rows: Vec<Row> = repos
.iter()
.enumerate()
.map(|(row, repo)| {
let selected = view.selected.get(row).copied().unwrap_or(false);
let built = row_for(repo, view, selected);
if selected {
built.style(Style::default().bg(view.colors.multi_select_bg))
} else {
built
}
})
.collect();
let table = Table::new(rows, widths(repos, view, table_area.width))
.header(header_row(view))
.column_spacing(1)
.row_highlight_style(view.colors.selection_style());
let offset =
settled_offset(view.offset.get(), cursor, repos.len(), viewport);
view.offset.set(offset);
let mut state = TableState::default()
.with_offset(offset)
.with_selected(Some(cursor));
frame.render_stateful_widget(table, table_area, &mut state);
if overflow {
let bar_area = Rect {
x: area.x,
y: area.y + 1,
width: area.width,
height: area.height.saturating_sub(1),
};
ratada::scroll::render_scrollbar(
frame,
bar_area,
view.skin,
ratada::nav::ScrollView {
total: repos.len(),
offset,
viewport,
},
);
}
}
fn widths(
repos: &[&Repo],
view: &TableView,
available: u16,
) -> Vec<Constraint> {
let cols = &view.config.column_widths;
let name = sized(
content_width(repos, |r| {
name_plain(&r.display_name(), shown_slug(r, view))
}),
4,
cols.name,
);
let lead: &[Constraint] = if view.has_selection {
&[
Constraint::Length(2),
Constraint::Length(1),
Constraint::Length(1),
]
} else {
&[Constraint::Length(1), Constraint::Length(1)]
};
if view.columns.is_statistics() {
let mut constraints = lead.to_vec();
constraints.push(Constraint::Length(name));
constraints.extend(
stat_columns(view.columns)
.iter()
.map(|column| Constraint::Length(column.width())),
);
constraints.push(Constraint::Min(0));
return constraints;
}
match view.tab {
Tab::FilesAndFolders => [
lead,
&[
Constraint::Length(name),
Constraint::Length(6),
Constraint::Min(20),
],
]
.concat(),
_ => {
let branch = sized(
content_width(repos, |r| {
branch_text(effective_info(r, view.example_mode))
}),
6,
cols.current_branch_name,
);
let status = sized(
content_width(repos, |r| status_display(r, view)),
6,
cols.status,
);
let github_desired = sized(
content_width(repos, |r| {
github_text(effective_info(r, view.example_mode))
}),
6,
cols.github_repo_name,
);
let zip = sized(
content_width(repos, |r| zip_date_text(r, view)),
"ZIP Backup".len(),
cols.zip_backup,
);
let lead_cells: u16 = if view.has_selection { 3 } else { 2 };
let lead_width: u16 = if view.has_selection { 4 } else { 2 };
let spacing = (lead_cells + 5).saturating_sub(1); let fixed = lead_width + name + branch + status + zip + spacing;
let github = github_width(github_desired, fixed, available);
[
lead,
&[
Constraint::Length(name),
Constraint::Length(branch),
Constraint::Length(status),
Constraint::Length(github),
Constraint::Length(zip),
],
]
.concat()
}
}
}
fn content_width<F>(repos: &[&Repo], cell: F) -> usize
where
F: Fn(&Repo) -> String,
{
repos
.iter()
.map(|repo| UnicodeWidthStr::width(cell(repo).as_str()))
.max()
.unwrap_or(0)
}
fn sized(content: usize, header: usize, width: ColumnWidth) -> u16 {
let floor = width.min.max(header);
let mut chosen = content.max(floor);
if let Some(max) = width.max {
chosen = chosen.min(max.max(floor));
}
chosen as u16
}
fn github_width(desired: u16, fixed: u16, available: u16) -> u16 {
desired.min(available.saturating_sub(fixed))
}
fn status_display(repo: &Repo, view: &TableView) -> String {
let info = effective_info(repo, view.example_mode);
match info {
None => "\u{2026}".to_string(),
Some(info) if info.is_path_missing() => "-".to_string(),
Some(info) => status_text(info, view.icons),
}
}
fn header_row(view: &TableView) -> Row<'static> {
let titles: Vec<String> = if view.columns.is_statistics() {
let mut titles = vec![String::new(), String::new(), "Name".to_string()];
titles.extend(
stat_columns(view.columns)
.iter()
.map(|column| column.title().to_string()),
);
titles.push(String::new());
titles
} else {
match view.tab {
Tab::FilesAndFolders => vec!["", "", "Name", "Type", "Path"],
_ => {
vec!["", "", "Name", "Branch", "Status", "GitHub", "ZIP Backup"]
}
}
.into_iter()
.map(str::to_string)
.collect()
};
let mut cells: Vec<Cell> = Vec::new();
if view.has_selection {
cells.push(Cell::from(""));
}
cells.extend(titles.into_iter().map(Cell::from));
Row::new(cells).style(view.colors.header_style())
}
fn row_for<'a>(repo: &Repo, view: &TableView, selected: bool) -> Row<'a> {
let mut cells: Vec<Cell> = Vec::new();
if view.has_selection {
cells.push(selection_cell(selected, view.colors));
}
cells.push(marker_cell(repo, view));
cells.push(fav_cell(repo, view));
cells.push(Cell::from(Line::from(name_cell_spans(repo, view))));
if view.columns.is_statistics() {
for column in stat_columns(view.columns) {
let text = view.stat_text(repo, *column);
let cell = if column.is_numeric() {
Cell::from(Line::from(text).right_aligned())
} else {
Cell::from(text)
};
cells.push(cell);
}
cells.push(Cell::from(""));
return Row::new(cells);
}
match view.tab {
Tab::FilesAndFolders => {
cells.push(Cell::from(type_label(repo)));
cells.push(Cell::from(Span::styled(
repo.path.to_string_lossy().into_owned(),
Style::default().fg(view.colors.dim),
)));
}
_ => {
let info = effective_info(repo, view.example_mode);
let branch_cap = view
.config
.column_widths
.current_branch_name
.max
.unwrap_or(usize::MAX);
cells.push(Cell::from(truncate(&branch_text(info), branch_cap)));
cells.push(status_cell(repo, info, view));
cells.push(Cell::from(github_text(info)));
cells.push(Cell::from(Span::styled(
zip_date_text(repo, view),
Style::default().fg(view.colors.dim),
)));
}
}
Row::new(cells)
}
fn selection_cell<'a>(selected: bool, colors: &Colors) -> Cell<'a> {
let symbol = if selected { "\u{25b8} " } else { " " };
Cell::from(Span::styled(
symbol,
Style::default()
.fg(colors.accent)
.add_modifier(Modifier::BOLD),
))
}
fn marker_cell<'a>(repo: &Repo, view: &TableView) -> Cell<'a> {
let errored = match repo.kind {
RepoKind::Git if view.example_mode => repo.example_error().is_some(),
RepoKind::Git => repo.entry_error().is_some(),
RepoKind::Path => view.missing.contains(&repo.path),
};
if errored {
Cell::from(Span::styled(
view.icons.missing.to_string(),
Style::default()
.fg(view.colors.danger)
.add_modifier(Modifier::BOLD),
))
} else {
Cell::from(" ")
}
}
fn fav_cell<'a>(repo: &Repo, view: &TableView) -> Cell<'a> {
if repo.fav {
Cell::from(Span::styled(
view.icons.favourite.to_string(),
Style::default().fg(view.colors.favourite),
))
} else {
Cell::from(" ")
}
}
fn status_cell<'a>(
repo: &Repo,
info: Option<&GitInfo>,
view: &TableView,
) -> Cell<'a> {
if let Some((in_flight, glyph)) = view.spinner
&& in_flight.contains(&repo.path)
{
return Cell::from(Span::styled(
glyph.to_string(),
Style::default().fg(view.colors.accent),
));
}
let Some(info) = info else {
return Cell::from(Span::styled(
"…",
Style::default().fg(view.colors.dim),
));
};
if info.is_path_missing() {
return Cell::from(Span::styled(
"-",
Style::default().fg(view.colors.dim),
));
}
let text = status_text(info, view.icons);
let style = if info.is_clean() {
Style::default().fg(view.colors.positive)
} else if has_changes(info) {
Style::default().fg(view.colors.changes)
} else {
Style::default()
};
Cell::from(Span::styled(text, style))
}
fn has_changes(info: &GitInfo) -> bool {
info.changes.unwrap_or(0) > 0
}
fn branch_text(info: Option<&GitInfo>) -> String {
match info {
None => "…".to_string(),
Some(info) => info
.current_branch_name
.clone()
.unwrap_or_else(|| "-".to_string()),
}
}
fn github_text(info: Option<&GitInfo>) -> String {
info.and_then(|info| info.github_repo_name.clone())
.unwrap_or_else(|| "-".to_string())
}
fn zip_date_text(repo: &Repo, view: &TableView) -> String {
if !repo.include_in_backup {
return view.icons.excluded.to_string();
}
view.zip_backups
.get(&repo.path)
.map_or_else(|| "-".to_string(), |dt| dt.format("%Y-%m-%d").to_string())
}
fn type_label(repo: &Repo) -> &'static str {
if is_dir_target(&repo.path) {
"folder"
} else {
"file"
}
}
#[cfg(test)]
mod tests {
use super::{github_width, settled_offset};
#[test]
fn settled_offset_stays_put_when_cursor_moves_up_but_is_visible() {
assert_eq!(settled_offset(4, 5, 20, 5), 4);
assert_eq!(settled_offset(4, 4, 20, 5), 4);
}
#[test]
fn settled_offset_scrolls_up_only_at_the_top_edge() {
assert_eq!(settled_offset(4, 3, 20, 5), 3);
}
#[test]
fn settled_offset_pages_down_at_the_bottom_edge() {
assert_eq!(settled_offset(4, 9, 20, 5), 5);
}
#[test]
fn github_keeps_content_width_when_there_is_room() {
assert_eq!(github_width(20, 60, 200), 20);
}
#[test]
fn github_yields_width_when_tight() {
assert_eq!(github_width(39, 64, 90), 26);
}
#[test]
fn github_collapses_rather_than_overflowing() {
assert_eq!(github_width(39, 95, 90), 0);
}
}