use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::time::{SystemTime, UNIX_EPOCH};
use ratatui::style::Style;
use crate::github::PrSummary;
use crate::multiplexer::{AgentPane, AgentStatus};
use super::DashboardTab;
use super::super::agent;
use super::super::ansi;
use super::super::settings::{load_last_pane_id, save_hide_stale, save_last_pane_id};
use super::super::sort::SortMode;
use super::super::spinner::SPINNER_FRAMES;
use super::App;
impl App {
pub fn apply_filters(&mut self) {
self.agents = self.all_agents.clone();
if !self.filter_text.is_empty() {
let filter_lower = self.filter_text.to_lowercase();
let window_prefix = self.config.window_prefix();
self.agents.retain(|a| {
let project = Self::extract_project_name(a).to_lowercase();
let (worktree, _) = agent::extract_worktree_name(
&a.session,
&a.window_name,
window_prefix,
&a.path,
);
let worktree_lower = worktree.to_lowercase();
project.contains(&filter_lower) || worktree_lower.contains(&filter_lower)
});
}
if self.hide_stale {
let threshold = self.stale_threshold_secs;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
self.agents.retain(|agent| {
agent
.status_ts
.map(|ts| now.saturating_sub(ts) <= threshold)
.unwrap_or(true)
});
}
self.sort_agents();
if let Some(ref pane_id) = self.selected_pane_id {
if let Some(new_idx) = self.agents.iter().position(|a| &a.pane_id == pane_id) {
self.table_state.select(Some(new_idx));
} else {
self.selected_pane_id = None;
if self.agents.is_empty() {
self.table_state.select(None);
} else if let Some(selected) = self.table_state.selected() {
if selected >= self.agents.len() {
self.table_state.select(Some(self.agents.len() - 1));
}
if let Some(idx) = self.table_state.selected() {
self.selected_pane_id = self.agents.get(idx).map(|a| a.pane_id.clone());
}
}
}
} else if let Some(selected) = self.table_state.selected() {
if selected >= self.agents.len() {
self.table_state.select(if self.agents.is_empty() {
None
} else {
Some(self.agents.len() - 1)
});
}
if let Some(idx) = self.table_state.selected() {
self.selected_pane_id = self.agents.get(idx).map(|a| a.pane_id.clone());
}
}
if self.selected_pane_id.is_none()
&& self.table_state.selected().is_none()
&& !self.agents.is_empty()
{
self.table_state.select(Some(0));
self.selected_pane_id = self.agents.first().map(|a| a.pane_id.clone());
}
self.update_preview();
}
fn parse_pane_id(pane_id: &str) -> u64 {
pane_id
.strip_prefix('%')
.unwrap_or(pane_id)
.parse()
.unwrap_or(u64::MAX)
}
fn sort_agents(&mut self) {
let stale_threshold = self.stale_threshold_secs;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let get_priority = |agent: &AgentPane| -> u8 {
let is_stale = agent
.status_ts
.map(|ts| now.saturating_sub(ts) > stale_threshold)
.unwrap_or(false);
if is_stale {
return 4; }
match agent.status {
Some(AgentStatus::Waiting) => 0, Some(AgentStatus::Done) => 1, Some(AgentStatus::Working) => 2, None => 3, }
};
let get_elapsed = |agent: &AgentPane| -> u64 {
agent
.status_ts
.map(|ts| now.saturating_sub(ts))
.unwrap_or(u64::MAX)
};
let pane_num = |agent: &AgentPane| Self::parse_pane_id(&agent.pane_id);
match self.sort_mode {
SortMode::Priority => {
self.agents
.sort_by_cached_key(|a| (get_priority(a), get_elapsed(a), pane_num(a)));
}
SortMode::Project => {
self.agents.sort_by_cached_key(|a| {
(Self::extract_project_name(a), get_priority(a), pane_num(a))
});
}
SortMode::Recency => {
self.agents
.sort_by_cached_key(|a| (get_elapsed(a), pane_num(a)));
}
SortMode::Natural => {
self.agents.sort_by_cached_key(pane_num);
}
}
}
pub fn cycle_sort_mode(&mut self) {
self.sort_mode = self.sort_mode.next();
self.sort_mode.save();
self.sort_agents();
}
pub fn toggle_scope_mode(&mut self) {
self.scope_mode = self.scope_mode.toggle();
self.scope_mode.save();
self.refresh();
}
pub fn toggle_stale_filter(&mut self) {
self.hide_stale = !self.hide_stale;
save_hide_stale(self.hide_stale);
self.refresh();
}
pub fn next(&mut self) {
if self.agents.is_empty() {
return;
}
let i = match self.table_state.selected() {
Some(i) => {
if i >= self.agents.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.table_state.select(Some(i));
self.selected_pane_id = self.agents.get(i).map(|a| a.pane_id.clone());
self.update_preview();
}
pub fn previous(&mut self) {
if self.agents.is_empty() {
return;
}
let i = match self.table_state.selected() {
Some(i) => {
if i == 0 {
self.agents.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.table_state.select(Some(i));
self.selected_pane_id = self.agents.get(i).map(|a| a.pane_id.clone());
self.update_preview();
}
pub(crate) fn switch_to_pane_and_track(&mut self, target_pane_id: &str) {
let current_pane = self.mux.active_pane_id();
let window_hint = self
.agents
.iter()
.find(|a| a.pane_id == target_pane_id)
.map(|a| a.window_name.as_str());
if self
.mux
.switch_to_pane(target_pane_id, window_hint)
.is_err()
{
return;
}
if self.mux.should_exit_on_jump() {
self.should_jump = true;
}
if let Some(ref current) = current_pane
&& current != target_pane_id
&& self.agents.iter().any(|a| a.pane_id == *current)
{
self.last_pane_id = Some(current.clone());
save_last_pane_id(current);
}
}
pub fn jump_to_selected(&mut self) {
if let Some(selected) = self.table_state.selected()
&& let Some(agent) = self.agents.get(selected)
{
let target = agent.pane_id.clone();
self.switch_to_pane_and_track(&target);
}
}
pub fn jump_to_index(&mut self, index: usize) {
if index < self.agents.len() {
self.table_state.select(Some(index));
self.selected_pane_id = self.agents.get(index).map(|a| a.pane_id.clone());
self.jump_to_selected();
}
}
pub fn jump_to_last(&mut self) {
self.last_pane_id = load_last_pane_id();
let Some(ref last_id) = self.last_pane_id else {
return;
};
let last_id = last_id.clone();
if let Some(idx) = self.agents.iter().position(|a| a.pane_id == last_id) {
self.table_state.select(Some(idx));
}
self.switch_to_pane_and_track(&last_id);
}
pub fn peek_selected(&mut self) {
if let Some(selected) = self.table_state.selected()
&& let Some(agent) = self.agents.get(selected)
{
let _ = self
.mux
.switch_to_pane(&agent.pane_id, Some(&agent.window_name));
}
}
pub fn kill_selected(&mut self) {
if let Some(selected) = self.table_state.selected()
&& let Some(agent) = self.agents.get(selected)
{
if agent.status == Some(AgentStatus::Working) {
self.pending_kill_pane_id = Some(agent.pane_id.clone());
} else {
self.do_kill(&agent.pane_id.clone());
}
}
}
pub fn confirm_kill(&mut self) {
if let Some(pane_id) = self.pending_kill_pane_id.take() {
self.do_kill(&pane_id);
}
}
fn do_kill(&mut self, pane_id: &str) {
let _ = self.mux.kill_pane(pane_id);
let selected = self.table_state.selected().unwrap_or(0);
self.agents.retain(|a| a.pane_id != pane_id);
self.all_agents.retain(|a| a.pane_id != pane_id);
if self.agents.is_empty() {
self.table_state.select(None);
self.selected_pane_id = None;
} else {
let new_idx = selected.min(self.agents.len() - 1);
self.table_state.select(Some(new_idx));
self.selected_pane_id = self.agents.get(new_idx).map(|a| a.pane_id.clone());
}
self.preview_pane_id = None;
self.update_preview();
}
pub fn send_key_to_selected(&self, key: &str) {
if let Some(selected) = self.table_state.selected()
&& let Some(agent) = self.agents.get(selected)
{
let _ = self.mux.send_key(&agent.pane_id, key);
}
}
pub fn format_duration(&self, secs: u64) -> String {
agent::format_duration(secs)
}
pub fn is_stale(&self, agent: &AgentPane) -> bool {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
agent::is_stale(agent.status_ts, self.stale_threshold_secs, now)
}
pub fn get_elapsed(&self, agent: &AgentPane) -> Option<u64> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
agent::elapsed_secs(agent.status_ts, now)
}
pub fn get_status_display(&self, agent: &AgentPane) -> Vec<(String, Style)> {
let is_stale = self.is_stale(agent);
let (icon, base_color, is_working) = match agent.status {
Some(AgentStatus::Working) => {
(self.config.status_icons.working(), self.palette.info, true)
}
Some(AgentStatus::Waiting) => (
self.config.status_icons.waiting(),
self.palette.accent,
false,
),
Some(AgentStatus::Done) => {
(self.config.status_icons.done(), self.palette.success, false)
}
None => ("", self.palette.text, false),
};
let base_style = Style::default().fg(base_color);
let mut spans = ansi::parse_tmux_styles(icon, base_style);
if is_stale {
let dimmed = Style::default().fg(self.palette.dimmed);
for span in &mut spans {
span.1 = dimmed;
}
spans.push((" \u{f051b}".to_string(), dimmed));
} else if is_working {
let spinner = SPINNER_FRAMES[self.spinner_frame as usize];
spans.push((format!(" {}", spinner), base_style));
}
spans
}
pub fn extract_worktree_name(&self, agent_pane: &AgentPane) -> (String, bool) {
agent::extract_worktree_name(
&agent_pane.session,
&agent_pane.window_name,
self.config.window_prefix(),
&agent_pane.path,
)
}
pub fn extract_project_name(agent_pane: &AgentPane) -> String {
agent::extract_project_name(&agent_pane.path)
}
pub fn get_pr_for_agent(&self, agent: &AgentPane) -> Option<&PrSummary> {
let repo_root = self.repo_roots.get(&agent.path)?;
let git_status = self.git_statuses.get(&agent.path)?;
let branch = git_status.branch.as_ref()?;
if branch == "main" || branch == "master" {
return None;
}
self.pr_statuses.get(repo_root)?.get(branch)
}
pub fn is_pr_fetching(&self) -> bool {
self.is_pr_fetching.load(Ordering::Relaxed)
}
pub fn has_any_pr(&self) -> bool {
self.agents
.iter()
.any(|agent| self.get_pr_for_agent(agent).is_some())
}
pub fn pr_statuses(&self) -> &HashMap<PathBuf, HashMap<String, PrSummary>> {
&self.pr_statuses
}
pub fn open_pr_for_selected(&mut self) {
self.open_pr_url(|url| url.to_string());
}
pub fn open_pr_checks_for_selected(&mut self) {
self.open_pr_url(|url| format!("{url}/checks"));
}
fn open_pr_url(&mut self, make_url: impl FnOnce(&str) -> String) {
let pr = match self.active_tab {
DashboardTab::Agents => self
.table_state
.selected()
.and_then(|i| self.agents.get(i))
.and_then(|agent| self.get_pr_for_agent(agent))
.cloned(),
DashboardTab::Worktrees => self
.worktree_table_state
.selected()
.and_then(|i| self.worktrees.get(i))
.and_then(|wt| wt.pr_info.clone()),
};
match pr {
Some(ref pr) if pr.url.is_some() => {
let url = make_url(pr.url.as_ref().unwrap());
#[cfg(target_os = "macos")]
let cmd = "open";
#[cfg(not(target_os = "macos"))]
let cmd = "xdg-open";
if let Err(e) = std::process::Command::new(cmd).arg(&url).spawn() {
self.status_message = Some((
format!("Failed to open browser: {e}"),
std::time::Instant::now(),
));
}
}
Some(_) => {
self.status_message = Some((
"PR URL not available yet".to_string(),
std::time::Instant::now(),
));
}
None => {
self.status_message = Some((
"No PR found for selected item".to_string(),
std::time::Instant::now(),
));
}
}
}
}