use ratatui::{
layout::Rect,
style::Style,
text::{Line, Span},
widgets::{ListItem, ListState},
};
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::mpsc::UnboundedSender;
use crate::action::Action;
use crate::git::status::RepoStatus;
use crate::repo_id::RepoId;
use crate::theme::Theme;
mod render;
#[cfg(test)]
mod tests;
#[derive(Default, Clone, Debug)]
pub(crate) struct SyncDiff {
pub added: Vec<PathBuf>,
pub removed: Vec<PathBuf>,
}
impl SyncDiff {
pub fn is_empty(&self) -> bool {
self.added.is_empty() && self.removed.is_empty()
}
}
#[derive(Clone, Debug)]
pub(crate) struct RepoEntry {
pub path: PathBuf,
pub name: String,
pub status: Option<RepoStatus>,
pub git_op: bool,
}
pub(crate) struct OpTarget {
pub exec_path: PathBuf,
pub branch: String,
pub parent_index: usize,
pub ahead: usize,
pub behind: usize,
pub has_submodules: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum DisplayRow {
Repo(usize),
Worktree(usize, usize), Stash(usize, usize), }
pub(crate) struct RepoList {
pub repos: Vec<RepoEntry>,
pub state: ListState,
pub render_area: Rect,
pub focused: bool,
action_tx: Option<UnboundedSender<Action>>,
expanded_repos: HashSet<RepoId>,
expanded_stashes: HashSet<RepoId>,
live_panes: Vec<(String, PathBuf)>,
display_rows: Vec<DisplayRow>,
theme: Arc<Theme>,
}
impl RepoList {
pub fn new(repo_paths: Vec<PathBuf>, theme: Arc<Theme>) -> Self {
let repos: Vec<RepoEntry> = repo_paths
.into_iter()
.map(|path| {
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string_lossy().to_string());
RepoEntry {
path,
name,
status: None,
git_op: false,
}
})
.collect();
let mut state = ListState::default();
if !repos.is_empty() {
state.select(Some(0));
}
let mut list = Self {
repos,
state,
render_area: Rect::default(),
focused: true,
action_tx: None,
expanded_repos: HashSet::new(),
expanded_stashes: HashSet::new(),
live_panes: Vec::new(),
display_rows: Vec::new(),
theme,
};
list.rebuild_display_rows();
list
}
pub fn set_theme(&mut self, theme: Arc<Theme>) {
self.theme = theme;
}
fn rebuild_display_rows(&mut self) {
self.display_rows.clear();
for (i, entry) in self.repos.iter().enumerate() {
self.display_rows.push(DisplayRow::Repo(i));
let id = RepoId(entry.path.clone());
if let Some(status) = &entry.status {
if self.expanded_repos.contains(&id) {
for j in 0..status.worktree_info.len() {
self.display_rows.push(DisplayRow::Worktree(i, j));
}
}
if self.expanded_stashes.contains(&id) {
for j in 0..status.stashes.len() {
self.display_rows.push(DisplayRow::Stash(i, j));
}
}
}
}
}
pub fn selected_index(&self) -> Option<usize> {
let di = self.state.selected()?;
match self.display_rows.get(di)? {
DisplayRow::Repo(i) => Some(*i),
DisplayRow::Worktree(ri, _) => Some(*ri),
DisplayRow::Stash(ri, _) => Some(*ri),
}
}
pub fn resolve_index(&self, id: &RepoId) -> Option<usize> {
self.repos.iter().position(|e| e.path == id.0)
}
pub fn resolve_target(&self, id: &RepoId) -> Option<OpTarget> {
if let Some(i) = self.resolve_index(id) {
let status = self.repos[i].status.as_ref();
return Some(OpTarget {
exec_path: self.repos[i].path.clone(),
branch: status.map(|s| s.branch.clone()).unwrap_or_default(),
parent_index: i,
ahead: status.map_or(0, |s| s.ahead),
behind: status.map_or(0, |s| s.behind),
has_submodules: status.is_some_and(|s| s.has_submodules),
});
}
for (i, entry) in self.repos.iter().enumerate() {
let Some(status) = entry.status.as_ref() else {
continue;
};
if let Some(wt) = status.worktree_info.iter().find(|w| w.path == id.0) {
return Some(OpTarget {
exec_path: wt.path.clone(),
branch: wt.branch.clone(),
parent_index: i,
ahead: wt.ahead,
behind: wt.behind,
has_submodules: false,
});
}
}
None
}
pub fn selected_repo(&self) -> Option<&RepoEntry> {
self.selected_index().and_then(|i| self.repos.get(i))
}
pub fn selected_worktree(&self) -> Option<(RepoId, &crate::git::status::WorktreeEntry)> {
let di = self.state.selected()?;
match self.display_rows.get(di)? {
DisplayRow::Repo(_) | DisplayRow::Stash(_, _) => None,
DisplayRow::Worktree(ri, wi) => {
let entry = self.repos.get(*ri)?;
let wt = entry.status.as_ref()?.worktree_info.get(*wi)?;
Some((RepoId(entry.path.clone()), wt))
}
}
}
pub fn worktree_remove_target(&self, id: &RepoId) -> Option<(PathBuf, PathBuf, String)> {
for entry in &self.repos {
let Some(status) = entry.status.as_ref() else {
continue;
};
if let Some(wt) = status.worktree_info.iter().find(|w| w.path == id.0) {
return Some((entry.path.clone(), wt.path.clone(), wt.branch.clone()));
}
}
None
}
pub fn live_panes(&self) -> &[(String, PathBuf)] {
&self.live_panes
}
pub fn set_live_panes(&mut self, panes: Vec<(String, PathBuf)>) {
self.live_panes = panes;
}
pub fn select_repo_row(&mut self, repo_idx: usize) {
for (di, row) in self.display_rows.iter().enumerate() {
if matches!(row, DisplayRow::Repo(i) if *i == repo_idx) {
self.state.select(Some(di));
return;
}
}
}
fn select_next(&mut self) {
if self.display_rows.is_empty() {
return;
}
let i = match self.state.selected() {
Some(i) => (i + 1).min(self.display_rows.len() - 1),
None => 0,
};
self.state.select(Some(i));
}
fn select_prev(&mut self) {
if self.display_rows.is_empty() {
return;
}
let i = match self.state.selected() {
Some(i) => i.saturating_sub(1),
None => 0,
};
self.state.select(Some(i));
}
pub fn sync_paths(&mut self, new_paths: Vec<PathBuf>) -> SyncDiff {
let current: HashSet<PathBuf> = self.repos.iter().map(|r| r.path.clone()).collect();
let desired: HashSet<PathBuf> = new_paths.iter().cloned().collect();
if current == desired
&& new_paths
.iter()
.zip(self.repos.iter())
.all(|(p, e)| p == &e.path)
{
return SyncDiff::default();
}
let mut by_path: std::collections::HashMap<PathBuf, RepoEntry> =
self.repos.drain(..).map(|e| (e.path.clone(), e)).collect();
let mut next: Vec<RepoEntry> = Vec::with_capacity(new_paths.len());
let mut added: Vec<PathBuf> = Vec::new();
for path in &new_paths {
if let Some(existing) = by_path.remove(path) {
next.push(existing);
} else {
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string_lossy().to_string());
next.push(RepoEntry {
path: path.clone(),
name,
status: None,
git_op: false,
});
added.push(path.clone());
}
}
let removed: Vec<PathBuf> = by_path.into_keys().collect();
for path in &removed {
let id = RepoId(path.clone());
self.expanded_repos.remove(&id);
self.expanded_stashes.remove(&id);
}
self.repos = next;
self.rebuild_display_rows();
SyncDiff { added, removed }
}
pub fn update_status(&mut self, index: usize, repo_status: RepoStatus) {
if let Some(entry) = self.repos.get_mut(index) {
entry.status = Some(repo_status);
entry.git_op = false;
}
self.rebuild_display_rows();
}
fn emit_selection_action(&self) -> Option<Action> {
let di = self.state.selected()?;
match self.display_rows.get(di)? {
DisplayRow::Repo(i) => {
let id = RepoId(self.repos[*i].path.clone());
Some(Action::SelectRepo(id))
}
DisplayRow::Worktree(ri, wi) => {
let entry = &self.repos[*ri];
let wt = entry.status.as_ref()?.worktree_info.get(*wi)?;
Some(Action::SelectWorktree {
repo_id: RepoId(entry.path.clone()),
worktree_path: wt.path.clone(),
worktree_branch: wt.branch.clone(),
})
}
DisplayRow::Stash(ri, _) => {
let id = RepoId(self.repos[*ri].path.clone());
Some(Action::FocusRepoDetails(id))
}
}
}
fn current_parent_repo(&self) -> Option<usize> {
let di = self.state.selected()?;
match self.display_rows.get(di)? {
DisplayRow::Repo(i) => Some(*i),
DisplayRow::Worktree(ri, _) => Some(*ri),
DisplayRow::Stash(ri, _) => Some(*ri),
}
}
fn snapshot_selection(&self) -> Option<DisplayRow> {
let di = self.state.selected()?;
self.display_rows.get(di).cloned()
}
fn restore_selection(&mut self, prev: Option<DisplayRow>) {
let Some(prev) = prev else { return };
let parent_idx = match &prev {
DisplayRow::Repo(i) => *i,
DisplayRow::Worktree(ri, _) => *ri,
DisplayRow::Stash(ri, _) => *ri,
};
if let Some(new_idx) = self.display_rows.iter().position(|r| *r == prev) {
self.state.select(Some(new_idx));
} else {
self.select_repo_row(parent_idx);
}
}
fn toggle_stash_expand(&mut self) {
let Some(repo_idx) = self.current_parent_repo() else {
return;
};
let entry = &self.repos[repo_idx];
let has_stashes = entry.status.as_ref().is_some_and(|s| !s.stashes.is_empty());
if !has_stashes {
return;
}
let id = RepoId(entry.path.clone());
let prev = self.snapshot_selection();
if self.expanded_stashes.contains(&id) {
self.expanded_stashes.remove(&id);
self.rebuild_display_rows();
self.select_repo_row(repo_idx);
} else {
self.expanded_stashes.insert(id);
self.rebuild_display_rows();
self.restore_selection(prev);
}
}
fn toggle_expand(&mut self) {
let Some(di) = self.state.selected() else {
return;
};
let repo_idx = match self.display_rows.get(di) {
Some(DisplayRow::Repo(i)) => *i,
Some(DisplayRow::Worktree(ri, _)) => *ri,
Some(DisplayRow::Stash(ri, _)) => *ri,
None => return,
};
let entry = &self.repos[repo_idx];
let has_worktrees = entry
.status
.as_ref()
.is_some_and(|s| !s.worktree_info.is_empty());
if !has_worktrees {
return;
}
let id = RepoId(entry.path.clone());
let prev = self.snapshot_selection();
if self.expanded_repos.contains(&id) {
self.expanded_repos.remove(&id);
self.rebuild_display_rows();
self.select_repo_row(repo_idx);
} else {
self.expanded_repos.insert(id);
self.rebuild_display_rows();
self.restore_selection(prev);
}
}
fn render_repo_item(&self, entry: &RepoEntry, _repo_idx: usize) -> ListItem<'static> {
let t = &self.theme.repo_list;
let mut spans = Vec::new();
if entry.git_op {
spans.push(Span::styled("~ ", Style::default().fg(t.git_op_marker)));
} else if entry.status.as_ref().map(|s| s.is_dirty).unwrap_or(false) {
spans.push(Span::styled("* ", Style::default().fg(t.dirty_marker)));
} else {
spans.push(Span::raw(" "));
}
if let Some(status) = &entry.status {
spans.push(Span::styled(
format!("{:<12} ", status.branch),
Style::default().fg(t.branch),
));
if status.ahead > 0 {
spans.push(Span::styled(
format!("\u{2191}{} ", status.ahead),
Style::default().fg(t.ahead),
));
}
if status.behind > 0 {
spans.push(Span::styled(
format!("\u{2193}{} ", status.behind),
Style::default().fg(t.behind),
));
}
if !status.stashes.is_empty() {
let id = RepoId(entry.path.clone());
let expanded = self.expanded_stashes.contains(&id);
let icon = if expanded { "\u{25bc}" } else { "\u{25b6}" };
spans.push(Span::styled(
format!("{}${} ", icon, status.stash_count()),
Style::default().fg(t.stash),
));
}
if !status.worktree_info.is_empty() {
let id = RepoId(entry.path.clone());
let expanded = self.expanded_repos.contains(&id);
let icon = if expanded { "\u{25bc}" } else { "\u{25b6}" };
spans.push(Span::styled(
format!("{}{} ", icon, status.worktree_info.len()),
Style::default().fg(t.worktree_count),
));
}
if status.has_dirty_submodules {
spans.push(Span::styled(
"\u{25c8} ",
Style::default().fg(t.dirty_submodule),
));
}
if status.has_unpushed_submodules {
spans.push(Span::styled(
"\u{21e1} ",
Style::default().fg(t.unpushed_submodule),
));
}
if status.fetch_failed {
spans.push(Span::styled(
"\u{26a0} ",
Style::default().fg(t.fetch_failed),
));
}
if !status.files.is_empty() {
spans.push(Span::styled(
format!("[{}] ", status.files.len()),
Style::default().fg(t.file_count),
));
}
}
spans.push(Span::styled(
entry.name.clone(),
Style::default().fg(t.repo_name),
));
if crate::session::liveness::is_live(&entry.path, &self.live_panes) {
spans.push(Span::styled(" \u{25c9}", Style::default().fg(t.live)));
}
ListItem::new(Line::from(spans))
}
fn render_worktree_item(&self, entry: &RepoEntry, wt_idx: usize) -> ListItem<'static> {
let t = &self.theme.repo_list;
let wt = &entry.status.as_ref().unwrap().worktree_info[wt_idx];
let mut spans = vec![Span::styled(
" \u{2387} ",
Style::default().fg(t.worktree_subtree_icon),
)];
if wt.is_dirty {
spans.push(Span::styled("* ", Style::default().fg(t.dirty_marker)));
} else {
spans.push(Span::raw(" "));
}
spans.push(Span::styled(
format!("{:<12} ", wt.branch),
Style::default().fg(t.worktree_subtree_branch),
));
if wt.ahead > 0 {
spans.push(Span::styled(
format!("\u{2191}{} ", wt.ahead),
Style::default().fg(t.ahead),
));
}
if wt.behind > 0 {
spans.push(Span::styled(
format!("\u{2193}{} ", wt.behind),
Style::default().fg(t.behind),
));
}
if wt.has_dirty_submodules {
spans.push(Span::styled(
"\u{25c8} ",
Style::default().fg(t.dirty_submodule),
));
}
if wt.has_unpushed_submodules {
spans.push(Span::styled(
"\u{21e1} ",
Style::default().fg(t.unpushed_submodule),
));
}
if wt.file_count > 0 {
spans.push(Span::styled(
format!("[{}] ", wt.file_count),
Style::default().fg(t.file_count),
));
}
if crate::session::liveness::is_live(&wt.path, &self.live_panes) {
spans.push(Span::styled("\u{25c9} ", Style::default().fg(t.live)));
}
ListItem::new(Line::from(spans))
}
fn render_stash_item(&self, entry: &RepoEntry, stash_idx: usize) -> ListItem<'static> {
let t = &self.theme.repo_list;
let stash = &entry.status.as_ref().unwrap().stashes[stash_idx];
let label = format!(" $ stash@{{{}}} ", stash.index);
let spans = vec![
Span::styled(label, Style::default().fg(t.stash)),
Span::styled(
stash.message.clone(),
Style::default().fg(t.worktree_subtree_icon),
),
];
ListItem::new(Line::from(spans))
}
}