use crate::dependencies::{ExternalTool, Git};
use std::path::Path;
use std::time::{Duration, Instant};
use unicode_width::UnicodeWidthStr;
use crate::tui::app::App;
pub(crate) const REFRESH_SECS: u64 = 15;
pub(super) fn refresh_if_needed(app: &mut App, now: Instant, allow_refresh: bool) {
if let Ok(mut cell) = app.workspace_context_cell.lock()
&& let Some(ctx) = cell.take()
{
if app.workspace_context.as_deref() != Some(ctx.as_str()) {
app.needs_redraw = true;
}
app.workspace_context = Some(ctx);
}
if app
.workspace_context_refreshed_at
.is_some_and(|refreshed_at| {
now.duration_since(refreshed_at) < Duration::from_secs(REFRESH_SECS)
})
{
return;
}
if !allow_refresh {
return;
}
if let Ok(handle) = tokio::runtime::Handle::try_current() {
let ctx = app.workspace_context_cell.clone();
let workspace = app.workspace.clone();
handle.spawn_blocking(move || {
let result = collect(&workspace);
if let Ok(mut guard) = ctx.lock() {
*guard = result;
}
});
} else {
app.workspace_context = collect(&app.workspace);
}
app.workspace_context_refreshed_at = Some(now);
}
pub(super) fn refresh_now(app: &mut App, now: Instant) {
if let Ok(mut cell) = app.workspace_context_cell.lock() {
*cell = None;
}
app.workspace_context_refreshed_at = None;
refresh_if_needed(app, now, true);
}
#[derive(Debug, Default, Clone, Copy)]
struct ChangeSummary {
staged: usize,
modified: usize,
untracked: usize,
conflicts: usize,
}
impl ChangeSummary {
fn is_clean(&self) -> bool {
self.staged == 0 && self.modified == 0 && self.untracked == 0 && self.conflicts == 0
}
}
fn collect(workspace: &Path) -> Option<String> {
let branch = branch(workspace)?;
let summary = change_summary(workspace)?;
let mut parts = Vec::new();
if summary.staged > 0 {
parts.push(format!("{} staged", summary.staged));
}
if summary.modified > 0 {
parts.push(format!("{} modified", summary.modified));
}
if summary.untracked > 0 {
parts.push(format!("{} untracked", summary.untracked));
}
if summary.conflicts > 0 {
parts.push(format!("{} conflicts", summary.conflicts));
}
let status = if summary.is_clean() {
"clean".to_string()
} else {
parts.join(", ")
};
Some(format!("{branch} | {status}"))
}
pub(crate) fn branch_from_context(context: &str) -> Option<&str> {
let (branch, _) = context.rsplit_once(" | ")?;
(!branch.is_empty()).then_some(branch)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WorkspaceIdentity {
pub name: String,
pub branch: Option<String>,
pub is_git: bool,
}
pub(crate) fn workspace_basename(workspace: &Path) -> String {
workspace
.file_name()
.and_then(|s| s.to_str())
.filter(|s| !s.is_empty())
.unwrap_or("(root)")
.to_string()
}
pub(crate) fn identity_from_context(workspace: &Path, context: Option<&str>) -> WorkspaceIdentity {
let branch = context.and_then(branch_from_context).map(str::to_string);
WorkspaceIdentity {
name: workspace_basename(workspace),
is_git: branch.is_some(),
branch,
}
}
pub(crate) fn format_repo_identity(identity: &WorkspaceIdentity, max_width: usize) -> String {
use crate::localization::truncate_to_width;
const PREFIX: &str = "Repo: ";
let prefix_width = PREFIX.width();
if max_width < prefix_width {
return String::new();
}
let mut candidates: Vec<String> = Vec::new();
match (&identity.branch, identity.is_git) {
(Some(branch), _) => {
candidates.push(format!("{PREFIX}{} @ {branch}", identity.name));
candidates.push(format!("{PREFIX}{}", identity.name));
}
(None, _) => {
candidates.push(format!("{PREFIX}{} (no git)", identity.name));
candidates.push(format!("{PREFIX}{}", identity.name));
}
}
for candidate in &candidates {
if candidate.width() <= max_width {
return candidate.clone();
}
}
truncate_to_width(&format!("{PREFIX}{}", identity.name), max_width)
}
pub(super) fn branch(workspace: &Path) -> Option<String> {
let branch = run_git(workspace, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
let branch = branch.trim().to_string();
if branch == "HEAD" || branch.is_empty() {
let short_hash = run_git(workspace, &["rev-parse", "--short", "HEAD"]).ok()?;
let short_hash = short_hash.trim();
if short_hash.is_empty() {
return None;
}
return Some(format!("detached:{short_hash}"));
}
Some(branch)
}
fn change_summary(workspace: &Path) -> Option<ChangeSummary> {
let status = run_git(
workspace,
&["status", "--short", "--untracked-files=normal"],
)
.ok()?;
if status.trim().is_empty() {
return Some(ChangeSummary::default());
}
let mut summary = ChangeSummary::default();
for line in status.lines() {
if line.trim().is_empty() {
continue;
}
let mut chars = line.chars();
let staged = chars.next()?;
let modified = chars.next().unwrap_or(' ');
if staged == ' ' && modified == ' ' {
continue;
}
if staged == '?' && modified == '?' {
summary.untracked = summary.untracked.saturating_add(1);
continue;
}
if staged == 'U' || modified == 'U' {
summary.conflicts = summary.conflicts.saturating_add(1);
}
if staged != ' ' && staged != '?' {
summary.staged = summary.staged.saturating_add(1);
}
if modified != ' ' && modified != '?' {
summary.modified = summary.modified.saturating_add(1);
}
}
Some(summary)
}
fn run_git(workspace: &Path, args: &[&str]) -> std::io::Result<String> {
let output = Git::output(args, workspace)?;
if !output.status.success() {
return Err(std::io::Error::other("git command failed"));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn identity_in_git_repo_carries_name_and_branch() {
let id = identity_from_context(
&PathBuf::from("/work/CodeWhale"),
Some("codex/v0.8.61 | 3 modified"),
);
assert_eq!(id.name, "CodeWhale");
assert_eq!(id.branch.as_deref(), Some("codex/v0.8.61"));
assert!(id.is_git);
assert_eq!(
format_repo_identity(&id, 80),
"Repo: CodeWhale @ codex/v0.8.61"
);
}
#[test]
fn identity_outside_git_uses_cwd_basename_with_explicit_state() {
let id = identity_from_context(&PathBuf::from("/tmp/scratch-dir"), None);
assert_eq!(id.name, "scratch-dir");
assert_eq!(id.branch, None);
assert!(!id.is_git);
assert_eq!(format_repo_identity(&id, 80), "Repo: scratch-dir (no git)");
}
#[test]
fn detached_head_branch_passes_through_to_label() {
let id = identity_from_context(
&PathBuf::from("/work/CodeWhale"),
Some("detached:ae101a1 | clean"),
);
assert_eq!(id.branch.as_deref(), Some("detached:ae101a1"));
assert_eq!(
format_repo_identity(&id, 80),
"Repo: CodeWhale @ detached:ae101a1"
);
}
#[test]
fn narrow_width_keeps_identity_over_branch_then_truncates() {
let id = identity_from_context(
&PathBuf::from("/work/CodeWhale"),
Some("codex/v0.8.61 | clean"),
);
let dropped = format_repo_identity(&id, 20);
assert_eq!(dropped, "Repo: CodeWhale");
assert!(dropped.width() <= 20);
let truncated = format_repo_identity(&id, 11);
assert!(truncated.width() <= 11, "{truncated:?} must fit width 11");
assert!(truncated.starts_with("Repo: "), "{truncated:?}");
assert!(truncated.ends_with('…'), "{truncated:?}");
assert_eq!(format_repo_identity(&id, 3), "");
}
#[test]
fn non_git_identity_degrades_before_truncating() {
let id = identity_from_context(&PathBuf::from("/tmp/scratch-dir"), None);
assert_eq!(format_repo_identity(&id, 18), "Repo: scratch-dir");
}
#[test]
fn workspace_basename_handles_root_path() {
assert_eq!(workspace_basename(Path::new("/")), "(root)");
assert_eq!(workspace_basename(Path::new("/a/b/project")), "project");
}
#[test]
fn collect_and_identity_agree_on_a_real_repo() {
if !Git::available() {
return;
}
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
let init = Git::output(&["init", "-q"], root);
if init.is_err() || !init.unwrap().status.success() {
return; }
let _ = Git::output(&["config", "user.email", "t@example.com"], root);
let _ = Git::output(&["config", "user.name", "Test"], root);
match collect(root) {
Some(ctx) => {
let id = identity_from_context(root, Some(ctx.as_str()));
assert!(id.is_git, "fresh repo should detect a git identity");
assert!(id.branch.is_some(), "repo must report a branch/HEAD");
let label = format_repo_identity(&id, 80);
assert!(label.starts_with("Repo: "), "{label:?}");
}
None => {
let id = identity_from_context(root, None);
assert!(!id.is_git);
assert!(format_repo_identity(&id, 80).starts_with("Repo: "));
}
}
}
}