use crate::event::{EventHandler, GitDataUpdate, TerminalEvent};
use crate::git_repo::GitRepo;
use crate::util::{strip_unc_prefix, strip_unc_pathbuf};
use color_eyre::Result;
use crossterm::{
event::{KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, widgets::TableState, Terminal};
use std::io;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FilterMode {
All,
NoUpstream,
Modified,
Behind,
}
impl FilterMode {
pub fn next(&self) -> Self {
match self {
FilterMode::All => FilterMode::NoUpstream,
FilterMode::NoUpstream => FilterMode::Behind,
FilterMode::Behind => FilterMode::Modified,
FilterMode::Modified => FilterMode::All,
}
}
pub fn previous(&self) -> Self {
match self {
FilterMode::All => FilterMode::Modified,
FilterMode::Modified => FilterMode::Behind,
FilterMode::Behind => FilterMode::NoUpstream,
FilterMode::NoUpstream => FilterMode::All,
}
}
pub fn display_name(&self) -> &str {
match self {
FilterMode::All => "All",
FilterMode::NoUpstream => "No Upstream",
FilterMode::Modified => "Modified",
FilterMode::Behind => "Behind",
}
}
}
pub struct App {
pub repos: Vec<GitRepo>,
pub scan_path: String,
pub table_state: TableState,
should_quit: bool,
needs_redraw: bool,
event_handler: EventHandler,
pub selected_repo: Option<String>,
pub fetching_repos: Vec<usize>,
pub cloning_repos: Vec<usize>,
pub deleting_repos: Vec<usize>,
pub fetch_animation_frame: usize,
pub filter_mode: FilterMode,
search_query: String,
search_mode: bool,
delete_confirmation: Option<usize>,
root_path: Option<std::path::PathBuf>,
pub cwd_file_enabled: bool,
}
impl App {
fn sort_repos(repos: &mut [GitRepo]) {
repos.sort_by(|a, b| {
match (a.is_missing(), b.is_missing()) {
(false, true) => std::cmp::Ordering::Less,
(true, false) => std::cmp::Ordering::Greater,
_ => {
let a_name = a.display_short().to_lowercase();
let b_name = b.display_short().to_lowercase();
a_name.cmp(&b_name)
}
}
});
}
fn find_repo_index(repos: &[GitRepo], path: &std::path::Path) -> Option<usize> {
repos.iter()
.position(|r| r.path() == path)
}
fn spawn_git_data_load(tx: tokio::sync::mpsc::UnboundedSender<GitDataUpdate>, idx: usize, path: std::path::PathBuf) {
let tx_clone = tx.clone();
tokio::spawn(async move {
let _ = tx_clone.send(GitDataUpdate::FetchProgress(idx));
let remote_status = tokio::task::spawn_blocking({
let path = path.clone();
move || GitRepo::read_remote_status(&path)
})
.await
.unwrap_or_else(|_| "error".to_string());
let status = tokio::task::spawn_blocking(move || GitRepo::read_status(&path))
.await
.unwrap_or_else(|_| "error".to_string());
let _ = tx_clone.send(GitDataUpdate::RemoteStatus(idx, remote_status));
let _ = tx_clone.send(GitDataUpdate::Status(idx, status));
let _ = tx_clone.send(GitDataUpdate::FetchComplete(idx));
});
}
fn spawn_manual_update(tx: tokio::sync::mpsc::UnboundedSender<GitDataUpdate>, idx: usize, path: std::path::PathBuf) {
let tx_clone = tx.clone();
tokio::spawn(async move {
let _ = tx_clone.send(GitDataUpdate::FetchProgress(idx));
let remote_status = tokio::task::spawn_blocking({
let path = path.clone();
move || GitRepo::read_remote_status(&path)
})
.await
.unwrap_or_else(|_| "error".to_string());
if remote_status != "local-only" && remote_status != "error" {
let fetch_result = tokio::task::spawn_blocking({
let path = path.clone();
move || GitRepo::fetch(&path, true) })
.await;
if fetch_result.is_ok() {
let new_remote_status = tokio::task::spawn_blocking({
let path = path.clone();
move || GitRepo::read_remote_status(&path)
})
.await
.unwrap_or_else(|_| "error".to_string());
let _ = tx_clone.send(GitDataUpdate::RemoteStatus(idx, new_remote_status));
}
} else {
let _ = tx_clone.send(GitDataUpdate::RemoteStatus(idx, remote_status));
}
let status = tokio::task::spawn_blocking(move || GitRepo::read_status(&path))
.await
.unwrap_or_else(|_| "error".to_string());
let _ = tx_clone.send(GitDataUpdate::Status(idx, status));
let _ = tx_clone.send(GitDataUpdate::FetchComplete(idx));
});
}
pub fn new(repos: Vec<GitRepo>, scan_path: &Path, fetch: bool, update: bool) -> Self {
Self::new_with_root(repos, scan_path, fetch, update, None, false)
}
pub fn new_with_root(
mut repos: Vec<GitRepo>,
scan_path: &Path,
fetch: bool,
update: bool,
root_path: Option<std::path::PathBuf>,
cwd_file_enabled: bool,
) -> Self {
Self::sort_repos(&mut repos);
let mut table_state = TableState::default();
if !repos.is_empty() {
table_state.select(Some(0));
}
let path_str = scan_path.display().to_string();
let display_path = strip_unc_prefix(&path_str).to_string();
let repos_clone = repos.clone();
let event_handler = EventHandler::new(
repos.len(),
move |idx| repos_clone[idx].path().to_path_buf(),
fetch,
update,
);
Self {
repos,
scan_path: display_path,
table_state,
should_quit: false,
needs_redraw: false,
event_handler,
selected_repo: None,
fetching_repos: Vec::new(),
cloning_repos: Vec::new(),
deleting_repos: Vec::new(),
fetch_animation_frame: 0,
filter_mode: FilterMode::All,
search_query: String::new(),
search_mode: false,
delete_confirmation: None,
root_path,
cwd_file_enabled,
}
}
pub async fn run(&mut self) -> Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = self.run_loop(&mut terminal).await;
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
pub fn repos(&self) -> &[GitRepo] {
&self.repos
}
async fn run_loop(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
let mut animation_interval = tokio::time::interval(tokio::time::Duration::from_millis(100));
loop {
terminal.draw(|f| f.render_widget(&mut *self, f.area()))?;
self.needs_redraw = false;
if self.should_quit {
break;
}
tokio::select! {
result = self.event_handler.next() => {
if let Some(event) = result? {
self.handle_event(event)?;
}
}
_ = animation_interval.tick() => {
if !self.fetching_repos.is_empty() || !self.cloning_repos.is_empty() || !self.deleting_repos.is_empty() {
self.fetch_animation_frame = (self.fetch_animation_frame + 1) % 10;
self.needs_redraw = true;
}
}
}
}
Ok(())
}
fn handle_event(&mut self, event: TerminalEvent) -> Result<()> {
match event {
TerminalEvent::Key(code, modifiers) => {
if self.is_confirmation_mode() {
self.handle_confirmation_key(code);
} else if self.search_mode {
self.handle_search_key(code);
} else {
self.handle_normal_key(code, modifiers);
}
}
TerminalEvent::GitUpdate(update) => self.handle_git_update(update),
}
Ok(())
}
fn handle_search_key(&mut self, code: KeyCode) {
match code {
KeyCode::Esc => {
self.search_mode = false;
self.search_query.clear();
self.table_state.select(Some(0));
self.needs_redraw = true;
}
KeyCode::Enter => {
self.search_mode = false;
self.needs_redraw = true;
}
KeyCode::Backspace => {
self.search_query.pop();
self.table_state.select(Some(0));
self.needs_redraw = true;
}
KeyCode::Char(c) => {
self.search_query.push(c);
self.table_state.select(Some(0));
self.needs_redraw = true;
}
_ => {}
}
}
fn handle_normal_key(&mut self, code: KeyCode, modifiers: KeyModifiers) {
match code {
KeyCode::Char('q') | KeyCode::Char('Q') => {
self.should_quit = true;
}
KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
self.should_quit = true;
}
KeyCode::Enter => {
if self.cwd_file_enabled &&
let Some(repo) = self.table_state.selected().and_then(|i| self.repos.get(i)) {
self.selected_repo = Some(repo.path().display().to_string());
self.should_quit = true;
}
}
KeyCode::Down | KeyCode::Char('j') => {
self.next();
}
KeyCode::Up | KeyCode::Char('k') => {
self.previous();
}
KeyCode::Char('[') => {
self.filter_mode = self.filter_mode.previous();
self.table_state.select(Some(0));
self.needs_redraw = true;
}
KeyCode::Char(']') => {
self.filter_mode = self.filter_mode.next();
self.table_state.select(Some(0));
self.needs_redraw = true;
}
KeyCode::Char('/') => {
self.search_mode = true;
self.search_query.clear();
self.needs_redraw = true;
}
KeyCode::Char('d') | KeyCode::Char('D') => {
self.handle_drop_repo();
}
KeyCode::Char('c') | KeyCode::Char('C') => {
self.handle_clone_repo();
}
KeyCode::Char('u') | KeyCode::Char('U') => {
self.handle_update_repo();
}
_ => {}
}
}
fn handle_confirmation_key(&mut self, code: KeyCode) {
match code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
self.perform_drop_repo();
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
self.cancel_confirmation();
}
_ => {}
}
}
fn handle_update_repo(&mut self) {
let Some(selected) = self.table_state.selected() else {
return;
};
let Some(repo) = self.repos.get(selected) else {
return;
};
if repo.is_missing() {
return;
}
if !self.fetching_repos.contains(&selected) {
self.fetching_repos.push(selected);
}
self.needs_redraw = true;
let tx = self.event_handler.git_tx();
let path = repo.path().to_path_buf();
let idx = selected;
Self::spawn_manual_update(tx, idx, path);
}
fn handle_git_update(&mut self, update: GitDataUpdate) {
match update {
GitDataUpdate::RemoteStatus(idx, status) => {
if let Some(repo) = self.repos.get_mut(idx) {
repo.set_remote_status(status);
self.needs_redraw = true;
}
}
GitDataUpdate::Status(idx, status) => {
if let Some(repo) = self.repos.get_mut(idx) {
repo.set_status(status);
self.needs_redraw = true;
}
}
GitDataUpdate::FetchProgress(idx) => {
if !self.fetching_repos.contains(&idx) {
self.fetching_repos.push(idx);
self.needs_redraw = true;
}
}
GitDataUpdate::FetchComplete(idx) => {
self.fetching_repos.retain(|&i| i != idx);
self.fetch_animation_frame = (self.fetch_animation_frame + 1) % 10;
self.needs_redraw = true;
}
GitDataUpdate::CloneProgress(idx) => {
if !self.cloning_repos.contains(&idx) {
self.cloning_repos.push(idx);
self.needs_redraw = true;
}
}
GitDataUpdate::CloneComplete(idx) => {
self.cloning_repos.retain(|&i| i != idx);
if let Some(repo) = self.repos.get(idx) {
let path = repo.path().to_path_buf();
if path.exists() {
self.repos[idx] = GitRepo::new(path.clone());
Self::sort_repos(&mut self.repos);
if let Some(new_idx) = Self::find_repo_index(&self.repos, &path) {
self.table_state.select(Some(new_idx));
Self::spawn_git_data_load(self.event_handler.git_tx(), new_idx, path);
}
}
}
self.needs_redraw = true;
}
GitDataUpdate::DeleteProgress(idx) => {
if !self.deleting_repos.contains(&idx) {
self.deleting_repos.push(idx);
self.needs_redraw = true;
}
}
GitDataUpdate::DeleteComplete(idx) => {
self.deleting_repos.retain(|&i| i != idx);
if let Some(repo) = self.repos.get_mut(idx) {
let repo_path = repo.path().to_path_buf();
repo.set_missing();
Self::sort_repos(&mut self.repos);
if let Some(new_idx) = Self::find_repo_index(&self.repos, &repo_path) {
self.table_state.select(Some(new_idx));
}
}
self.needs_redraw = true;
}
}
}
pub fn filtered_repos(&self) -> Vec<usize> {
self.repos
.iter()
.enumerate()
.filter(|(_, repo)| self.matches_search(repo) && self.matches_filter(repo))
.map(|(idx, _)| idx)
.collect()
}
fn matches_search(&self, repo: &GitRepo) -> bool {
if self.search_query.is_empty() {
return true;
}
let query_lower = self.search_query.to_lowercase();
let name_match = repo.name()
.map(|n| n.to_lowercase().contains(&query_lower))
.unwrap_or(false);
let parent_match = repo.parent_name()
.map(|p| p.to_lowercase().contains(&query_lower))
.unwrap_or(false);
name_match || parent_match
}
fn matches_filter(&self, repo: &GitRepo) -> bool {
if repo.is_missing() && self.filter_mode != FilterMode::All {
return false;
}
match self.filter_mode {
FilterMode::All => true,
FilterMode::NoUpstream => {
let remote = repo.remote_status();
remote == "local-only" || remote == "no-tracking"
}
FilterMode::Modified => {
let status = repo.status();
status != "clean" && status != "loading..."
}
FilterMode::Behind => {
repo.remote_status().contains('↓')
}
}
}
pub fn is_search_mode(&self) -> bool {
self.search_mode
}
pub fn search_query(&self) -> &str {
&self.search_query
}
pub fn is_confirmation_mode(&self) -> bool {
self.delete_confirmation.is_some()
}
pub fn confirmation_repo_name(&self) -> Option<String> {
self.delete_confirmation
.and_then(|idx| self.repos.get(idx))
.map(|repo| repo.display_short().to_string())
}
fn cancel_confirmation(&mut self) {
self.delete_confirmation = None;
self.needs_redraw = true;
}
fn next(&mut self) {
let filtered = self.filtered_repos();
if filtered.is_empty() {
return;
}
let current_selected = self.table_state.selected().unwrap_or(0);
let current_pos = filtered.iter().position(|&idx| idx == current_selected);
let next_pos = match current_pos {
Some(pos) if pos >= filtered.len() - 1 => 0,
Some(pos) => pos + 1,
None => 0,
};
self.table_state.select(Some(filtered[next_pos]));
}
fn previous(&mut self) {
let filtered = self.filtered_repos();
if filtered.is_empty() {
return;
}
let current_selected = self.table_state.selected().unwrap_or(0);
let current_pos = filtered.iter().position(|&idx| idx == current_selected);
let prev_pos = match current_pos {
Some(0) | None => filtered.len() - 1,
Some(pos) => pos - 1,
};
self.table_state.select(Some(filtered[prev_pos]));
}
fn handle_drop_repo(&mut self) {
let Some(selected) = self.table_state.selected() else {
return;
};
let Some(_repo) = self.repos.get(selected) else {
return;
};
self.delete_confirmation = Some(selected);
self.needs_redraw = true;
}
fn perform_drop_repo(&mut self) {
let Some(selected) = self.delete_confirmation.take() else {
return;
};
let Some(repo) = self.repos.get(selected) else {
return;
};
let is_missing = repo.is_missing();
let repo_path = repo.path().to_path_buf();
if is_missing {
if let Some(root_path) = &self.root_path {
let cleaned_path = strip_unc_pathbuf(repo_path.as_path());
if let Ok(relative_path) = cleaned_path.strip_prefix(root_path)
&& crate::config::remove_from_cache(relative_path).is_ok()
{
self.repos.remove(selected);
if !self.repos.is_empty() {
let new_selected = if selected >= self.repos.len() {
self.repos.len() - 1
} else {
selected
};
self.table_state.select(Some(new_selected));
} else {
self.table_state.select(None);
}
self.needs_redraw = true;
}
}
} else {
self.deleting_repos.push(selected);
self.needs_redraw = true;
let tx = self.event_handler.git_tx();
let idx = selected;
tokio::spawn(async move {
let _ = tx.send(GitDataUpdate::DeleteProgress(idx));
let delete_result = tokio::task::spawn_blocking(move || {
std::fs::remove_dir_all(&repo_path)
}).await;
let _ = tx.send(GitDataUpdate::DeleteComplete(idx));
drop(delete_result); });
}
}
fn handle_clone_repo(&mut self) {
let Some(selected) = self.table_state.selected() else {
return;
};
let Some(repo) = self.repos.get(selected) else {
return;
};
if !repo.is_missing() {
return;
}
self.cloning_repos.push(selected);
self.needs_redraw = true;
let repo_clone = repo.clone();
let tx = self.event_handler.git_tx();
let idx = selected;
tokio::spawn(async move {
let _ = tx.send(GitDataUpdate::CloneProgress(idx));
let clone_result = tokio::task::spawn_blocking(move || {
repo_clone.clone_repository()
}).await;
let _ = tx.send(GitDataUpdate::CloneComplete(idx));
if clone_result.is_ok() {
}
});
}
}