use color_eyre::Result;
use crossterm::event::{KeyCode, KeyModifiers};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
use crate::action::Action;
use crate::components::Component;
use crate::components::confirm_dialog::ConfirmDialog;
use crate::components::context_menu::ContextMenu;
use crate::components::file_list::FileList;
use crate::components::git_graph::GitGraph;
use crate::components::github_panel::GithubPanel;
use crate::components::graph_menu::context_menu::GraphContextMenu;
use crate::components::graph_menu::filter_picker::GraphFilterPicker;
use crate::components::path_input::PathInput;
use crate::components::picker::Picker;
use crate::components::repo_list::RepoEntry;
use crate::components::repo_list::RepoList;
use crate::components::status_bar::StatusBar;
use crate::components::theme_picker::ThemePicker;
use crate::config::BranchFilter;
use crate::config::Config;
use crate::config::UpdatePosition;
use crate::event::Event;
use crate::git::graph::GraphOptions;
use crate::git::scanner;
use crate::git::status::RepoStatus;
use crate::repo_id::RepoId;
use crate::session::visibility::PowerState;
use crate::theme::{Theme, discover_all_theme_names, load_theme};
use crate::tui::Tui;
use crate::watcher::RepoWatcher;
mod actions;
mod actions_extra;
mod github;
mod input;
mod launch;
mod render;
#[cfg(test)]
mod tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FocusPanel {
Repos,
Changes,
Graph,
GitHub,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SortOrder {
Alphabetical,
ReverseAlphabetical,
DirtyFirst,
}
impl SortOrder {
fn next(self) -> Self {
match self {
Self::Alphabetical => Self::ReverseAlphabetical,
Self::ReverseAlphabetical => Self::DirtyFirst,
Self::DirtyFirst => Self::Alphabetical,
}
}
pub(crate) fn label(self) -> &'static str {
match self {
Self::Alphabetical => "A-Z",
Self::ReverseAlphabetical => "Z-A",
Self::DirtyFirst => "Dirty",
}
}
}
struct StatusGuard {
id: RepoId,
tx: UnboundedSender<Action>,
completed: bool,
}
impl StatusGuard {
fn new(id: RepoId, tx: UnboundedSender<Action>) -> Self {
Self {
id,
tx,
completed: false,
}
}
fn complete(mut self) {
self.completed = true;
}
}
impl Drop for StatusGuard {
fn drop(&mut self) {
if !self.completed {
let _ = self.tx.send(Action::StatusQueryDone(self.id.clone()));
}
}
}
struct GitOpGuard {
id: RepoId,
tx: UnboundedSender<Action>,
completed: bool,
}
static MUTATING_GIT_OPS: AtomicUsize = AtomicUsize::new(0);
pub(crate) fn mutating_git_ops() -> usize {
MUTATING_GIT_OPS.load(Ordering::SeqCst)
}
impl GitOpGuard {
fn new(id: RepoId, tx: UnboundedSender<Action>) -> Self {
MUTATING_GIT_OPS.fetch_add(1, Ordering::SeqCst);
Self {
id,
tx,
completed: false,
}
}
fn complete(mut self) {
self.completed = true;
}
}
impl Drop for GitOpGuard {
fn drop(&mut self) {
MUTATING_GIT_OPS.fetch_sub(1, Ordering::SeqCst);
if !self.completed {
let _ = self.tx.send(Action::RefreshRepo(self.id.clone()));
}
}
}
pub(crate) struct App {
config: Config,
should_quit: bool,
force_quit: bool,
repo_list: RepoList,
file_list: FileList,
git_graph: GitGraph,
graph_context_menu: GraphContextMenu,
graph_filter_picker: GraphFilterPicker,
github_panel: GithubPanel,
confirm_dialog: ConfirmDialog,
context_menu: ContextMenu,
path_input: PathInput,
status_bar: StatusBar,
theme_picker: ThemePicker,
picker: Picker,
pending_pick: Option<PendingPick>,
focus: FocusPanel,
sort_order: SortOrder,
action_tx: UnboundedSender<Action>,
action_rx: UnboundedReceiver<Action>,
repo_area: Rect,
changes_area: Rect,
graph_area: Rect,
github_area: Rect,
github_cache: HashMap<RepoId, github::GithubState>,
github_forced: Option<bool>,
github_visible: bool,
github_select_gen: u64,
github_state_filter: github::GithubStateFilter,
error_message: Option<(String, Instant)>,
success_message: Option<(String, Instant)>,
clipboard: Option<arboard::Clipboard>,
dragging_border: Option<u8>,
border_frac: [f64; 3],
horizontal_layout: bool,
update_version: Option<String>,
update_position: UpdatePosition,
show_help: bool,
poll_semaphore: Arc<tokio::sync::Semaphore>,
pending_status: HashSet<RepoId>,
dirty_repos: HashSet<RepoId>,
last_refresh: HashMap<RepoId, Instant>,
refresh_scheduled: HashSet<RepoId>,
active_worktree: Option<ActiveWorktree>,
liveness_probe_in_flight: bool,
theme: Arc<crate::theme::Theme>,
watcher: Arc<Mutex<Option<RepoWatcher>>>,
tui_event_tx: Option<UnboundedSender<Event>>,
last_discovery: Option<Instant>,
discovery_pending: bool,
power: PowerState,
discovery_deferred: bool,
}
#[derive(Clone)]
struct ActiveWorktree {
path: std::path::PathBuf,
repo_id: RepoId,
display_name: String,
}
struct PendingLaunch {
dir: std::path::PathBuf,
command: Option<String>,
base: Option<String>,
label: &'static str,
}
enum PendingPick {
Launch(PendingLaunch),
GotoSession,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RefreshDecision {
Now,
Later(Duration),
}
fn refresh_decision(last: Option<Instant>, now: Instant, cooldown: Duration) -> RefreshDecision {
if let Some(last) = last {
let elapsed = now.saturating_duration_since(last);
if elapsed < cooldown {
return RefreshDecision::Later(cooldown - elapsed);
}
}
RefreshDecision::Now
}
fn graph_status_changed(
previous: Option<&RepoStatus>,
next: &RepoStatus,
filter: BranchFilter,
) -> bool {
let Some(previous) = previous else {
return true;
};
let prev_refs = &previous.refs;
let next_refs = &next.refs;
let refs_changed = match filter {
BranchFilter::All => prev_refs != next_refs,
BranchFilter::Local => {
prev_refs.local != next_refs.local || prev_refs.tags != next_refs.tags
}
BranchFilter::Remote => {
prev_refs.remote != next_refs.remote || prev_refs.tags != next_refs.tags
}
BranchFilter::None => false,
};
refs_changed
|| previous.branch != next.branch
|| previous.head_oid != next.head_oid
|| previous.ahead != next.ahead
|| previous.behind != next.behind
|| previous.stashes.len() != next.stashes.len()
|| previous
.stashes
.iter()
.zip(next.stashes.iter())
.any(|(previous, next)| previous.oid != next.oid)
}
impl App {
pub fn new(config: Config) -> Self {
let repo_paths = scanner::discover_repos(&config);
let (action_tx, action_rx) = mpsc::unbounded_channel();
let theme = Arc::new(config.theme.clone());
let mut git_graph = GitGraph::new(theme.clone());
git_graph.graph_options = GraphOptions {
branch_filter: config.graph.branches,
label_max_len: config.graph.label_max_len,
first_parent: false,
show_stats: config.graph.show_stats,
filters: crate::git::graph::GraphFilters::default(),
};
let update_position = config.ui.update_position;
let poll_semaphore = Arc::new(tokio::sync::Semaphore::new(
config.watch.max_concurrent_polls,
));
let roots = config.root_dirs.clone();
let mut app = Self {
config,
should_quit: false,
force_quit: false,
repo_list: RepoList::new(repo_paths, roots, theme.clone()),
file_list: FileList::new(theme.clone()),
git_graph,
graph_context_menu: GraphContextMenu::new(theme.clone()),
graph_filter_picker: GraphFilterPicker::new(theme.clone()),
github_panel: GithubPanel::new(theme.clone()),
confirm_dialog: ConfirmDialog::new(theme.clone()),
context_menu: ContextMenu::new(theme.clone()),
path_input: PathInput::new(theme.clone()),
status_bar: StatusBar::new(theme.clone()),
theme_picker: ThemePicker::new(theme.clone()),
picker: Picker::new(theme.clone()),
pending_pick: None,
focus: FocusPanel::Repos,
sort_order: SortOrder::Alphabetical,
action_tx,
action_rx,
repo_area: Rect::default(),
changes_area: Rect::default(),
graph_area: Rect::default(),
github_area: Rect::default(),
github_cache: HashMap::new(),
github_forced: None,
github_visible: false,
github_select_gen: 0,
github_state_filter: github::GithubStateFilter::default(),
error_message: None,
success_message: None,
clipboard: None,
dragging_border: None,
border_frac: [0.25, 0.50, 0.78],
horizontal_layout: false,
update_version: None,
update_position,
show_help: false,
poll_semaphore,
pending_status: HashSet::new(),
dirty_repos: HashSet::new(),
last_refresh: HashMap::new(),
refresh_scheduled: HashSet::new(),
active_worktree: None,
liveness_probe_in_flight: false,
theme,
watcher: Arc::new(Mutex::new(None)),
tui_event_tx: None,
last_discovery: None,
discovery_pending: false,
power: PowerState::Awake,
discovery_deferred: false,
};
app.sort_repos();
app.repo_list.select_repo_row(0);
app
}
fn sort_repos(&mut self) {
let keep = self.repo_list.selected_row_id();
match self.sort_order {
SortOrder::Alphabetical => {
self.repo_list
.repos
.sort_by_cached_key(|r| r.display.to_lowercase());
}
SortOrder::ReverseAlphabetical => {
self.repo_list
.repos
.sort_by_cached_key(|r| std::cmp::Reverse(r.display.to_lowercase()));
}
SortOrder::DirtyFirst => {
self.repo_list.repos.sort_by_cached_key(|r| {
let dirty = r.status.as_ref().is_some_and(|s| s.is_dirty);
(!dirty, r.display.to_lowercase())
});
}
}
self.repo_list.resync_rows(keep);
}
fn rebuild_watcher(&mut self) {
let Some(tx) = self.tui_event_tx.clone() else {
return;
};
let repo_paths: Vec<_> = self
.repo_list
.repos
.iter()
.map(|r| r.path.clone())
.collect();
let root_dirs = self.config.root_dirs.clone();
let debounce_ms = self.config.watch.debounce_ms;
let exclude_dirs = self.config.watch.watch_exclude_dirs.clone();
let watch_worktree_dirs = self.config.watch.watch_worktree_dirs;
let slot = Arc::clone(&self.watcher);
let repo_count = repo_paths.len();
tokio::task::spawn_blocking(move || {
let started = std::time::Instant::now();
match RepoWatcher::new(
&repo_paths,
&root_dirs,
debounce_ms,
tx,
&exclude_dirs,
watch_worktree_dirs,
) {
Ok(w) => {
*slot.lock().unwrap() = Some(w);
tracing::info!(
"filesystem watcher ready: {} repos in {:?}",
repo_count,
started.elapsed()
);
}
Err(e) => tracing::warn!(
"Failed to rebuild filesystem watcher; keeping previous watches: {}",
e
),
}
});
}
fn sync_selection(&mut self) {
self.github_touch_selection();
if self.active_worktree.is_some() {
self.refresh_active_worktree();
return;
}
if let Some(idx) = self.repo_list.selected_index()
&& let Some(entry) = self.repo_list.repos.get(idx)
{
let name = entry.name.clone();
let repo_id = RepoId(entry.path.clone());
let files = entry
.status
.as_ref()
.map(|s| s.files.clone())
.unwrap_or_default();
self.file_list.set_files(files, &name, repo_id);
let path = entry.path.clone();
self.git_graph.load_repo(path, &name);
}
}
fn apply_theme(&mut self, theme: Arc<Theme>) {
self.theme = theme.clone();
self.repo_list.set_theme(theme.clone());
self.file_list.set_theme(theme.clone());
self.git_graph.set_theme(theme.clone());
self.graph_context_menu.set_theme(theme.clone());
self.graph_filter_picker.set_theme(theme.clone());
self.github_panel.set_theme(theme.clone());
self.confirm_dialog.set_theme(theme.clone());
self.context_menu.set_theme(theme.clone());
self.path_input.set_theme(theme.clone());
self.status_bar.set_theme(theme.clone());
self.theme_picker.set_theme(theme.clone());
self.picker.set_theme(theme);
}
fn schedule_refresh(&mut self, id: &RepoId) {
if self.pending_status.contains(id) {
self.dirty_repos.insert(id.clone());
tracing::debug!("skipping repo {}: already in-flight (marked dirty)", id);
return;
}
let cooldown = Duration::from_millis(self.config.watch.refresh_cooldown_ms);
let now = Instant::now();
match refresh_decision(self.last_refresh.get(id).copied(), now, cooldown) {
RefreshDecision::Now => {
self.refresh_scheduled.remove(id);
self.spawn_refresh_query(id.clone());
}
RefreshDecision::Later(wait) => {
if self.refresh_scheduled.insert(id.clone()) {
let repo_id = id.clone();
let tx = self.action_tx.clone();
tokio::spawn(async move {
tokio::time::sleep(wait).await;
let _ = tx.send(Action::RefreshRepoAfterCooldown(repo_id));
});
}
}
}
}
pub async fn run(&mut self) -> Result<()> {
let mut tui = Tui::new()?
.mouse(true)
.poll_local_interval(std::time::Duration::from_secs(
self.config.watch.poll_local_secs,
))
.poll_fetch_interval(std::time::Duration::from_secs(
self.config.watch.poll_fetch_secs,
))
.sleep_when_hidden(self.config.watch.sleep_when_hidden)
.doze_after(std::time::Duration::from_secs(
self.config.watch.doze_after_secs,
));
tui.enter()?;
self.repo_list
.register_action_handler(self.action_tx.clone())?;
self.file_list
.register_action_handler(self.action_tx.clone())?;
self.git_graph
.register_action_handler(self.action_tx.clone())?;
self.context_menu
.register_action_handler(self.action_tx.clone())?;
self.theme_picker
.register_action_handler(self.action_tx.clone());
self.repo_list.init()?;
self.action_tx.send(Action::PollLocal)?;
self.tui_event_tx = Some(tui.event_tx.clone());
self.rebuild_watcher();
if self.config.ui.check_for_updates {
let tx = self.action_tx.clone();
tokio::task::spawn_blocking(move || {
if let Some(version) = crate::update_checker::check_latest() {
let _ = tx.send(Action::UpdateAvailable(version));
}
});
}
if !crate::git::git_available() {
self.action_tx.send(Action::Error(
"git not found on PATH: viewing works, but fetch/pull and submodule actions need git"
.to_string(),
))?;
}
self.sync_selection();
loop {
let event = if self.should_quit {
tokio::time::timeout(Duration::from_millis(200), tui.event_rx.recv())
.await
.unwrap_or(None)
} else {
tui.event_rx.recv().await
};
if let Some(event) = event {
self.handle_event(event)?;
}
let mut dirty = false;
while let Ok(action) = self.action_rx.try_recv() {
dirty |= !matches!(action, Action::Tick | Action::Render);
self.handle_action(action, &mut tui)?;
}
if dirty {
self.handle_action(Action::Render, &mut tui)?;
}
if self.ready_to_exit() {
tui.exit()?;
break;
}
}
Ok(())
}
fn ready_to_exit(&self) -> bool {
self.should_quit && (self.force_quit || MUTATING_GIT_OPS.load(Ordering::SeqCst) == 0)
}
fn handle_event(&mut self, event: Event) -> Result<()> {
match event {
Event::Quit => {
self.action_tx.send(Action::Quit)?;
}
Event::Tick => {
self.action_tx.send(Action::Tick)?;
}
Event::Render => {
self.action_tx.send(Action::Render)?;
}
Event::Key(key) => {
self.handle_key_event(key)?;
}
Event::Mouse(mouse) => {
self.handle_mouse_event(mouse)?;
}
Event::Paste(ref text) => {
if self.path_input.visible {
self.path_input.paste(text);
self.action_tx.send(Action::Render)?;
}
}
Event::Resize(w, h) => {
self.action_tx.send(Action::Resize(w, h))?;
}
Event::RepoChanged(ref path) => {
if self.power != PowerState::DeepSleep {
self.action_tx
.send(Action::RefreshRepo(RepoId(path.clone())))?;
}
}
Event::ReposRootChanged => {
if self.power == PowerState::DeepSleep {
self.discovery_deferred = true;
return Ok(());
}
let cooldown =
std::time::Duration::from_secs(self.config.watch.discovery_cooldown_secs);
let now = Instant::now();
let elapsed = self.last_discovery.map(|t| now.duration_since(t));
let in_cooldown = elapsed.is_some_and(|d| d < cooldown);
if !in_cooldown {
self.last_discovery = Some(now);
self.discovery_pending = false;
self.action_tx.send(Action::DiscoverNewRepos)?;
} else if !self.discovery_pending {
self.discovery_pending = true;
let wait = cooldown.saturating_sub(elapsed.unwrap_or_default());
let tx = self.action_tx.clone();
tokio::spawn(async move {
tokio::time::sleep(wait).await;
let _ = tx.send(Action::DiscoverNewRepos);
});
}
}
Event::PollLocal => {
self.action_tx.send(Action::PollLocal)?;
}
Event::PollFetch => {
self.action_tx.send(Action::PollFetch)?;
}
Event::FocusGained => {
if let Some(entry) = self.repo_list.selected_repo() {
self.action_tx
.send(Action::RefreshRepo(RepoId(entry.path.clone())))?;
}
}
Event::Power(state) => {
let was_deep = self.power == PowerState::DeepSleep;
self.power = state;
if was_deep && state != PowerState::DeepSleep {
if self.discovery_deferred {
self.discovery_deferred = false;
self.handle_event(Event::ReposRootChanged)?;
}
self.action_tx.send(Action::Render)?;
}
}
_ => {}
}
Ok(())
}
}
fn base64_encode(data: &[u8]) -> String {
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut result = String::new();
for chunk in data.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
let n = (b0 << 16) | (b1 << 8) | b2;
result.push(CHARS[(n >> 18 & 0x3f) as usize] as char);
result.push(CHARS[(n >> 12 & 0x3f) as usize] as char);
if chunk.len() > 1 {
result.push(CHARS[(n >> 6 & 0x3f) as usize] as char);
} else {
result.push('=');
}
if chunk.len() > 2 {
result.push(CHARS[(n & 0x3f) as usize] as char);
} else {
result.push('=');
}
}
result
}