use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::{bail, Result};
use crate::{
cache::SessionSnapshot,
config::global::GlobalConfig,
git::{info as git_info, worktree as git_worktree},
hooks,
model::workspace::{
session_display_name_from_tmux, FetchFailReason, ForegroundKind, GitInfo, Project,
ProjectConfig, SessionInfo, WorkspaceState, WorktreeInfo,
},
tmux::{monitor::SessionStatus, session},
};
type PaneSnap = HashMap<String, (Option<String>, bool)>;
type WorktreeSnap = HashMap<PathBuf, WorktreeSnapEntry>;
struct WorktreeSnapEntry {
git_info: Option<GitInfo>,
git_info_fetched_at: Option<Instant>,
expanded: bool,
panes: PaneSnap,
session_order: Vec<String>,
last_fetched: Option<Instant>,
fetch_failed: bool,
fetch_fail_count: u32,
fetch_fail_reason: Option<FetchFailReason>,
}
pub const IDLE_SECS: u64 = 3;
fn is_git_repo(path: &std::path::Path) -> bool {
path.exists() && path.join(".git").exists()
}
fn unix_ts_to_instant(unix_ts: u64) -> Option<Instant> {
let now_unix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let secs_ago = now_unix.saturating_sub(unix_ts);
Instant::now().checked_sub(Duration::from_secs(secs_ago))
}
pub fn refresh_workspace(
workspace: &mut WorkspaceState,
config: &GlobalConfig,
sessions_with_paths: &[(String, PathBuf)],
activity: &HashMap<String, SessionStatus>,
) {
let worktrees: Vec<(PathBuf, Vec<git_worktree::WorktreeEntry>)> = workspace
.projects
.iter()
.map(|p| {
let entries = git_worktree::list_worktrees(&p.path).unwrap_or_default();
(p.path.clone(), entries)
})
.collect();
refresh_workspace_with_worktrees(workspace, config, sessions_with_paths, activity, worktrees);
}
pub fn refresh_workspace_with_worktrees(
workspace: &mut WorkspaceState,
config: &GlobalConfig,
sessions_with_paths: &[(String, PathBuf)],
activity: &HashMap<String, SessionStatus>,
worktrees: Vec<(PathBuf, Vec<git_worktree::WorktreeEntry>)>,
) {
let mut sessions_by_path: HashMap<&PathBuf, Vec<&str>> = HashMap::new();
for (name, path) in sessions_with_paths {
sessions_by_path
.entry(path)
.or_default()
.push(name.as_str());
}
let aliases_by_path: Vec<(PathBuf, HashMap<String, String>)> = config
.projects
.iter()
.map(|e| (e.path.clone(), e.aliases.clone()))
.collect();
let mut worktrees_map: HashMap<PathBuf, Vec<git_worktree::WorktreeEntry>> =
worktrees.into_iter().collect();
for i in 0..workspace.projects.len() {
let path = workspace.projects[i].path.clone();
let proj_name = workspace.projects[i].name.clone();
let aliases = aliases_by_path
.iter()
.find(|(p, _)| p == &path)
.map(|(_, a)| a.clone())
.unwrap_or_default();
let snapshot: WorktreeSnap = workspace.projects[i]
.worktrees
.iter()
.map(|w| {
let panes = w
.sessions
.iter()
.map(|s| (s.name.clone(), (s.pane_capture.clone(), s.muted)))
.collect();
let order = w.sessions.iter().map(|s| s.name.clone()).collect();
(
w.path.clone(),
WorktreeSnapEntry {
git_info: w.git_info.clone(),
git_info_fetched_at: w.git_info_fetched_at,
expanded: w.expanded,
panes,
session_order: order,
last_fetched: w.last_fetched,
fetch_failed: w.fetch_failed,
fetch_fail_count: w.fetch_fail_count,
fetch_fail_reason: w.fetch_fail_reason.clone(),
},
)
})
.collect();
let entries = worktrees_map.remove(&path).unwrap_or_default();
let mut new_worktrees = Vec::new();
for entry in entries
.into_iter()
.filter(|e| !config.is_worktree_excluded(&e.path))
{
let alias = aliases.get(&entry.branch).cloned();
let wt_path = entry.path.clone();
let prev = snapshot.get(&entry.path);
let prev_order: &[String] = prev
.map(|snap| snap.session_order.as_slice())
.unwrap_or(&[]);
let order_index: HashMap<&str, usize> = prev_order
.iter()
.enumerate()
.map(|(i, n)| (n.as_str(), i))
.collect();
let empty_names: Vec<&str> = Vec::new();
let session_names = sessions_by_path.get(&wt_path).unwrap_or(&empty_names);
let mut sessions: Vec<SessionInfo> = session_names
.iter()
.map(|&name| {
let display_name = session_display_name_from_tmux(
name,
&proj_name,
&wt_path,
&entry.branch,
alias.as_deref(),
);
let prev_pane = prev.and_then(|snap| snap.panes.get(name));
let (pane_capture, prev_muted) = prev_pane
.map(|(p, m)| (p.clone(), *m))
.unwrap_or((None, false));
let status = activity.get(name);
let muted = status.map(|s| s.wsx_muted).unwrap_or(prev_muted);
let last_activity = status
.filter(|s| s.last_activity_ts > 0)
.and_then(|s| unix_ts_to_instant(s.last_activity_ts));
let (has_activity, last_activity) = if muted {
(false, None)
} else {
(status.map(|s| s.has_bell).unwrap_or(false), last_activity)
};
SessionInfo {
name: name.to_string(),
display_name,
has_activity,
pane_capture,
last_activity,
foreground: status
.map(|s| s.foreground)
.unwrap_or(ForegroundKind::Unknown),
is_running_wsx: status.map(|s| s.is_running_wsx).unwrap_or(false),
muted,
}
})
.collect();
sessions.sort_by_key(|s| *order_index.get(s.name.as_str()).unwrap_or(&usize::MAX));
let (
git_info,
git_info_fetched_at,
expanded,
last_fetched,
fetch_failed,
fetch_fail_count,
fetch_fail_reason,
) = prev
.map(|snap| {
(
snap.git_info.clone(),
snap.git_info_fetched_at,
snap.expanded,
snap.last_fetched,
snap.fetch_failed,
snap.fetch_fail_count,
snap.fetch_fail_reason.clone(),
)
})
.unwrap_or((None, None, true, None, false, 0, None));
new_worktrees.push(WorktreeInfo {
name: entry.name,
branch: entry.branch,
path: entry.path,
is_main: entry.is_main,
alias,
sessions,
expanded,
git_info,
git_info_fetched_at,
fetch_failed,
fetch_fail_count,
fetch_fail_reason,
last_fetched,
});
}
workspace.projects[i].worktrees = new_worktrees;
}
workspace.projects.retain(|p| !p.missing);
for p in &mut workspace.projects {
p.missing = !is_git_repo(&p.path);
}
}
pub fn update_activity(
workspace: &mut WorkspaceState,
activity: &HashMap<String, SessionStatus>,
) -> bool {
let mut changed = false;
for project in &mut workspace.projects {
for wt in &mut project.worktrees {
for sess in &mut wt.sessions {
if sess.muted {
continue;
}
let old_bell = sess.has_activity;
let old_foreground = sess.foreground;
if let Some(status) = activity.get(&sess.name) {
sess.has_activity = status.has_bell;
sess.foreground = status.foreground;
sess.is_running_wsx = status.is_running_wsx;
sess.last_activity = Some(status.last_activity_ts)
.filter(|&ts| ts > 0)
.and_then(|ts| unix_ts_to_instant(ts));
} else {
sess.has_activity = false;
sess.foreground = ForegroundKind::Unknown;
sess.is_running_wsx = false;
}
if sess.has_activity != old_bell || sess.foreground != old_foreground {
changed = true;
}
}
}
}
changed
}
pub fn load_workspace(config: &GlobalConfig) -> WorkspaceState {
if config.projects.is_empty() {
return WorkspaceState::empty();
}
let projects = config
.projects
.iter()
.filter_map(|entry| {
let path = &entry.path;
if !is_git_repo(path) {
return None;
}
let default_branch = detect_default_branch(path);
let proj_config = crate::config::project::load_project_config(path);
let entries = git_worktree::list_worktrees(path).unwrap_or_default();
let entries = entries
.into_iter()
.filter(|e| !config.is_worktree_excluded(&e.path))
.collect();
let worktrees = git_worktree::to_worktree_infos(entries, &entry.aliases);
Some(Project {
name: entry.name.clone(),
path: path.clone(),
default_branch,
worktrees,
config: Some(proj_config),
expanded: true,
missing: false,
})
})
.collect();
WorkspaceState { projects }
}
pub fn expand_path(s: &str) -> PathBuf {
if s.starts_with("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(&s[2..]);
}
}
PathBuf::from(s)
}
pub fn detect_default_branch(path: &std::path::Path) -> String {
git_info::current_branch(path).unwrap_or_else(|| "main".into())
}
pub fn register_project(path: PathBuf, config: &mut GlobalConfig) -> Result<Project> {
if path.as_os_str().is_empty() {
bail!("empty path");
}
let path = crate::config::global::normalize_project_path(&path);
if !path.exists() {
bail!("path does not exist: {}", path.display());
}
if !is_git_repo(&path) {
bail!("not a git repository: {}", path.display());
}
if config.projects.iter().any(|e| e.path == path) {
bail!("project already registered: {}", path.display());
}
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
let default_branch = detect_default_branch(&path);
let proj_config = crate::config::project::load_project_config(&path);
let entries = git_worktree::list_worktrees(&path).unwrap_or_default();
let aliases = config
.projects
.iter()
.find(|e| e.path == path)
.map(|e| e.aliases.clone())
.unwrap_or_default();
let worktrees = git_worktree::to_worktree_infos(entries, &aliases);
config.add_project(name.clone(), path.clone());
Ok(Project {
name,
path,
default_branch,
worktrees,
config: Some(proj_config),
expanded: true,
missing: false,
})
}
pub fn unregister_project(path: &PathBuf, config: &mut GlobalConfig) {
config.remove_project(path);
}
pub fn create_worktree(
repo_path: &PathBuf,
default_branch: &str,
proj_config: &ProjectConfig,
branch: &str,
) -> Result<(PathBuf, Option<String>)> {
let wt_path = git_worktree::create_worktree(repo_path, branch, default_branch)?;
let mut warning: Option<String> = None;
if let Err(e) = hooks::copy_env_files(repo_path, &wt_path, proj_config) {
warning = Some(format!("Warning: .env copy: {}", e));
}
if let Some(ref cmd) = proj_config.post_create {
if let Err(e) = hooks::run_post_create(&wt_path, cmd) {
warning = Some(format!("Warning: postCreate: {}", e));
}
}
Ok((wt_path, warning))
}
pub fn delete_worktree(
repo_path: &PathBuf,
wt_path: &PathBuf,
branch: &str,
session_names: &[String],
) -> Result<()> {
git_worktree::remove_worktree(repo_path, wt_path, branch)?;
for sess in session_names {
let _ = session::kill_session(sess);
}
Ok(())
}
pub fn create_session(
proj_name: &str,
wt_slug: &str,
wt_path: &PathBuf,
session_name: Option<String>,
command: Option<String>,
) -> Result<(String, String)> {
let base_display = match &session_name {
Some(n) if !n.is_empty() => n.clone(),
_ => match &command {
Some(cmd) => cmd
.split_whitespace()
.next()
.unwrap_or(proj_name)
.to_string(),
None => proj_name.to_string(),
},
};
let base_tmux = format!("{}-{}-{}", proj_name, wt_slug, base_display);
let tmux_name = session::unique_session_name(&base_tmux);
let prefix_len = proj_name.len() + 1 + wt_slug.len() + 1;
let display_name = tmux_name[prefix_len..].to_string();
session::create_session(&tmux_name, wt_path)?;
if let Some(cmd) = command {
session::send_keys(&tmux_name, &cmd)?;
}
Ok((tmux_name, display_name))
}
pub fn rename_session(old_name: &str, new_name: &str) -> Result<()> {
session::rename_session(old_name, new_name)
}
pub fn restore_cached_sessions(workspace: &WorkspaceState, cached_pid: Option<u32>) -> usize {
let current_pid = session::server_pid();
if cached_pid.is_some() && cached_pid == current_pid {
return 0;
}
let live: HashSet<String> = session::list_sessions_with_paths()
.into_iter()
.map(|(name, _)| name)
.collect();
let source = choose_restore_source(
crate::cache::load_session_snapshot_with_meta(),
crate::cache::collect_session_names(workspace),
crate::cache::WorkspaceCache::load().written_at_unix_ms,
);
let mut restored = 0usize;
for (path_str, names) in &source {
let path = std::path::Path::new(path_str.as_str());
for name in names {
if !live.contains(name) && session::create_session(name, path).is_ok() {
restored += 1;
}
}
}
restored
}
fn choose_restore_source(
snapshot: SessionSnapshot,
workspace_sessions: HashMap<String, Vec<String>>,
workspace_written_at: Option<u64>,
) -> HashMap<String, Vec<String>> {
let snapshot_is_newer = match (snapshot.written_at_unix_ms, workspace_written_at) {
(None, _) => true,
(Some(_), None) => true,
(Some(snapshot_ms), Some(workspace_ms)) => snapshot_ms >= workspace_ms,
};
if !snapshot.sessions.is_empty() && (workspace_sessions.is_empty() || snapshot_is_newer) {
snapshot.sessions
} else {
workspace_sessions
}
}
pub fn set_alias(config: &mut GlobalConfig, proj_path: &PathBuf, branch: &str, alias: &str) {
config.set_alias(proj_path, branch, alias);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::workspace::{Project, WorkspaceState};
use std::collections::HashMap;
fn make_project(path: PathBuf) -> Project {
Project {
name: "test".to_string(),
path,
default_branch: "main".to_string(),
worktrees: vec![],
config: None,
expanded: true,
missing: false,
}
}
fn sessions(path: &str, names: &[&str]) -> HashMap<String, Vec<String>> {
HashMap::from([(
path.to_string(),
names.iter().map(|name| name.to_string()).collect(),
)])
}
#[test]
fn restore_source_uses_workspace_when_snapshot_is_older() {
let source = choose_restore_source(
SessionSnapshot {
sessions: sessions("/tmp/repo", &["old"]),
written_at_unix_ms: Some(10),
},
sessions("/tmp/repo", &["new"]),
Some(20),
);
assert_eq!(source["/tmp/repo"], vec!["new"]);
}
#[test]
fn restore_source_uses_snapshot_when_snapshot_is_newer() {
let source = choose_restore_source(
SessionSnapshot {
sessions: sessions("/tmp/repo", &["new"]),
written_at_unix_ms: Some(20),
},
sessions("/tmp/repo", &["old"]),
Some(10),
);
assert_eq!(source["/tmp/repo"], vec!["new"]);
}
#[test]
fn restore_source_keeps_legacy_snapshot_preference() {
let source = choose_restore_source(
SessionSnapshot {
sessions: sessions("/tmp/repo", &["legacy"]),
written_at_unix_ms: None,
},
sessions("/tmp/repo", &["workspace"]),
Some(20),
);
assert_eq!(source["/tmp/repo"], vec!["legacy"]);
}
#[test]
fn refresh_drops_project_whose_directory_was_deleted() {
let suffix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let base = std::env::temp_dir().join(format!("wsx-test-{}", suffix));
std::fs::create_dir_all(&base).unwrap();
let exists_path = base.join("real");
std::fs::create_dir_all(&exists_path).unwrap();
std::fs::create_dir(exists_path.join(".git")).unwrap();
let missing_path = base.join("ghost");
let config = GlobalConfig::default();
let activity: HashMap<String, crate::tmux::monitor::SessionStatus> = HashMap::new();
let mut workspace = WorkspaceState {
projects: vec![
make_project(exists_path.clone()),
make_project(missing_path.clone()),
],
};
refresh_workspace_with_worktrees(
&mut workspace,
&config,
&[],
&activity,
vec![
(exists_path.clone(), vec![]),
(missing_path.clone(), vec![]),
],
);
assert_eq!(workspace.projects.len(), 2);
assert!(workspace
.projects
.iter()
.any(|p| p.missing && p.path == missing_path));
refresh_workspace_with_worktrees(
&mut workspace,
&config,
&[],
&activity,
vec![(exists_path.clone(), vec![]), (missing_path, vec![])],
);
assert_eq!(workspace.projects.len(), 1);
assert_eq!(workspace.projects[0].path, exists_path);
let _ = std::fs::remove_dir_all(&base);
}
fn unique_base() -> PathBuf {
let suffix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let base = std::env::temp_dir().join(format!("wsx-test-register-{}", suffix));
std::fs::create_dir_all(&base).unwrap();
base
}
fn make_repo_dir(base: &std::path::Path, name: &str) -> PathBuf {
let p = base.join(name);
std::fs::create_dir_all(p.join(".git")).unwrap();
p
}
#[test]
fn given_trailing_slash_path_when_registered_then_returned_path_has_no_trailing_slash() {
let base = unique_base();
let repo = make_repo_dir(&base, "myrepo");
let with_slash = PathBuf::from(format!("{}/", repo.to_string_lossy()));
let mut config = GlobalConfig::default();
let project = register_project(with_slash, &mut config).unwrap();
assert_eq!(project.path, repo);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn given_valid_repo_when_registered_then_name_is_final_path_component() {
let base = unique_base();
let repo = make_repo_dir(&base, "coolproject");
let mut config = GlobalConfig::default();
let project = register_project(repo, &mut config).unwrap();
assert_eq!(project.name, "coolproject");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn given_valid_repo_when_registered_then_appended_to_config() {
let base = unique_base();
let repo = make_repo_dir(&base, "myrepo");
let mut config = GlobalConfig::default();
let _ = register_project(repo, &mut config).unwrap();
assert_eq!(config.projects.len(), 1);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn given_two_distinct_repos_when_both_registered_then_both_stored() {
let base = unique_base();
let repo_a = make_repo_dir(&base, "alpha");
let repo_b = make_repo_dir(&base, "beta");
let mut config = GlobalConfig::default();
register_project(repo_a, &mut config).unwrap();
register_project(repo_b, &mut config).unwrap();
assert_eq!(config.projects.len(), 2);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn given_same_exact_path_registered_twice_when_second_call_then_returns_err() {
let base = unique_base();
let repo = make_repo_dir(&base, "myrepo");
let mut config = GlobalConfig::default();
register_project(repo.clone(), &mut config).unwrap();
let second = register_project(repo, &mut config);
assert!(second.is_err());
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn given_same_exact_path_registered_twice_when_second_call_then_projects_len_stays_one() {
let base = unique_base();
let repo = make_repo_dir(&base, "myrepo");
let mut config = GlobalConfig::default();
register_project(repo.clone(), &mut config).unwrap();
let _ = register_project(repo, &mut config);
assert_eq!(config.projects.len(), 1);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn given_path_differing_only_by_trailing_slash_when_second_call_then_returns_err() {
let base = unique_base();
let repo = make_repo_dir(&base, "myrepo");
let with_slash = PathBuf::from(format!("{}/", repo.to_string_lossy()));
let mut config = GlobalConfig::default();
register_project(repo, &mut config).unwrap();
let second = register_project(with_slash, &mut config);
assert!(second.is_err());
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn given_path_differing_only_by_trailing_slash_when_second_call_then_projects_len_stays_one() {
let base = unique_base();
let repo = make_repo_dir(&base, "myrepo");
let with_slash = PathBuf::from(format!("{}/", repo.to_string_lossy()));
let mut config = GlobalConfig::default();
register_project(repo, &mut config).unwrap();
let _ = register_project(with_slash, &mut config);
assert_eq!(config.projects.len(), 1);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn given_empty_path_when_registered_then_returns_err() {
let mut config = GlobalConfig::default();
let result = register_project(PathBuf::from(""), &mut config);
assert!(result.is_err());
}
#[test]
fn given_empty_path_when_registered_then_projects_stays_empty() {
let mut config = GlobalConfig::default();
let _ = register_project(PathBuf::from(""), &mut config);
assert_eq!(config.projects.len(), 0);
}
#[test]
fn given_nonexistent_path_when_registered_then_returns_err() {
let base = unique_base();
let missing = base.join("does-not-exist");
let mut config = GlobalConfig::default();
let result = register_project(missing, &mut config);
assert!(result.is_err());
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn given_existing_dir_without_git_when_registered_then_returns_err() {
let base = unique_base();
let plain = base.join("plaindir");
std::fs::create_dir_all(&plain).unwrap();
let mut config = GlobalConfig::default();
let result = register_project(plain, &mut config);
assert!(result.is_err());
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn given_file_at_path_instead_of_dir_when_registered_then_returns_err() {
let base = unique_base();
let file_path = base.join("notadir");
std::fs::write(&file_path, b"i am a file").unwrap();
let mut config = GlobalConfig::default();
let result = register_project(file_path, &mut config);
assert!(result.is_err());
let _ = std::fs::remove_dir_all(&base);
}
}