use std::io::IsTerminal;
use crate::config;
use crate::config::MuxMode;
use crate::multiplexer::{AgentStatus, create_backend, detect_backend};
use crate::util::format_compact_age;
use crate::workflow::types::AgentStatusSummary;
use crate::{git, nerdfont, workflow};
use anyhow::Result;
use pathdiff::diff_paths;
use serde::Serialize;
use tabled::{
Table, Tabled,
settings::{Padding, Style, disable::Remove, location::ByColumnName, object::Columns},
};
#[derive(Tabled)]
struct WorktreeRow {
#[tabled(rename = "BRANCH")]
branch: String,
#[tabled(rename = "AGE")]
age: String,
#[tabled(rename = "PR")]
pr_status: String,
#[tabled(rename = "AGENT")]
agent_status: String,
#[tabled(rename = "MUX")]
mux_status: String,
#[tabled(rename = "UNMERGED")]
unmerged_status: String,
#[tabled(rename = "PATH")]
path_str: String,
}
fn format_pr_status(pr_info: Option<crate::github::PrSummary>) -> String {
pr_info
.map(|pr| {
let icons = nerdfont::pr_icons();
let (icon, color) = match pr.state.as_str() {
"OPEN" if pr.is_draft => (icons.draft, "\x1b[90m"), "OPEN" => (icons.open, "\x1b[32m"), "MERGED" => (icons.merged, "\x1b[35m"), "CLOSED" => (icons.closed, "\x1b[31m"), _ => (icons.open, "\x1b[32m"),
};
format!("#{} {}{}\x1b[0m", pr.number, color, icon)
})
.unwrap_or_else(|| "-".to_string())
}
fn format_status_label(status: AgentStatus, config: &config::Config, use_icons: bool) -> String {
if use_icons {
match status {
AgentStatus::Working => config.status_icons.working().to_string(),
AgentStatus::Waiting => config.status_icons.waiting().to_string(),
AgentStatus::Done => config.status_icons.done().to_string(),
}
} else {
match status {
AgentStatus::Working => "working".to_string(),
AgentStatus::Waiting => "waiting".to_string(),
AgentStatus::Done => "done".to_string(),
}
}
}
fn format_agent_status(
summary: Option<&AgentStatusSummary>,
config: &config::Config,
use_icons: bool,
) -> String {
let summary = match summary {
Some(s) if !s.statuses.is_empty() => s,
_ => return "-".to_string(),
};
let total = summary.statuses.len();
if total == 1 {
format_status_label(summary.statuses[0], config, use_icons)
} else {
let working = summary
.statuses
.iter()
.filter(|s| matches!(s, AgentStatus::Working))
.count();
let waiting = summary
.statuses
.iter()
.filter(|s| matches!(s, AgentStatus::Waiting))
.count();
let done = summary
.statuses
.iter()
.filter(|s| matches!(s, AgentStatus::Done))
.count();
let mut parts = Vec::new();
if working > 0 {
let label = format_status_label(AgentStatus::Working, config, use_icons);
parts.push(format!("{}{}", working, label));
}
if waiting > 0 {
let label = format_status_label(AgentStatus::Waiting, config, use_icons);
parts.push(format!("{}{}", waiting, label));
}
if done > 0 {
let label = format_status_label(AgentStatus::Done, config, use_icons);
parts.push(format!("{}{}", done, label));
}
parts.join(" ")
}
}
#[derive(Serialize)]
struct JsonWorktree {
handle: String,
branch: String,
path: String,
is_main: bool,
mode: String,
has_uncommitted_changes: bool,
is_open: bool,
created_at: Option<u64>,
}
pub fn run(show_pr: bool, json: bool, filter: &[String]) -> Result<()> {
let config = config::Config::load(None)?;
let mux = create_backend(detect_backend());
let worktrees = workflow::list(&config, mux.as_ref(), show_pr && !json, filter)?;
if worktrees.is_empty() {
if json {
println!("[]");
} else {
println!("No worktrees found");
}
return Ok(());
}
if json {
let entries: Vec<JsonWorktree> = worktrees
.into_iter()
.map(|wt| JsonWorktree {
handle: wt.handle,
branch: wt.branch,
path: wt.path.to_string_lossy().to_string(),
is_main: wt.is_main,
mode: match wt.mode {
MuxMode::Window => "window".to_string(),
MuxMode::Session => "session".to_string(),
},
has_uncommitted_changes: git::has_uncommitted_changes(&wt.path).unwrap_or(false),
is_open: wt.has_mux_window,
created_at: wt.created_at,
})
.collect();
println!("{}", serde_json::to_string(&entries)?);
return Ok(());
}
let use_icons = std::io::stdout().is_terminal();
let current_dir = std::env::current_dir()?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let display_data: Vec<WorktreeRow> = worktrees
.into_iter()
.map(|wt| {
let path_str = diff_paths(&wt.path, ¤t_dir)
.map(|p| {
let s = p.display().to_string();
if s.is_empty() || s == "." {
"(here)".to_string()
} else {
s
}
})
.unwrap_or_else(|| wt.path.display().to_string());
let age = if wt.is_main {
"-".to_string()
} else {
wt.created_at
.map(|ts| format_compact_age(now.saturating_sub(ts)))
.unwrap_or_else(|| "-".to_string())
};
WorktreeRow {
branch: wt.branch,
age,
pr_status: format_pr_status(wt.pr_info),
agent_status: format_agent_status(wt.agent_status.as_ref(), &config, use_icons),
mux_status: if wt.has_mux_window {
"✓".to_string()
} else {
"-".to_string()
},
unmerged_status: if wt.has_unmerged {
"●".to_string()
} else {
"-".to_string()
},
path_str,
}
})
.collect();
let mut table = Table::new(display_data);
table
.with(Style::blank())
.modify(Columns::new(0..7), Padding::new(0, 1, 0, 0));
if !show_pr {
table.with(Remove::column(ByColumnName::new("PR")));
}
println!("{table}");
Ok(())
}