use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
pub enum ForegroundKind {
#[default]
Unknown,
Shell,
PassiveViewer,
Runtime,
Agent,
InteractiveApp,
}
#[derive(Debug, Clone, Serialize)]
pub struct WorkspaceState {
pub projects: Vec<Project>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Project {
pub name: String,
pub path: PathBuf,
pub default_branch: String,
pub worktrees: Vec<WorktreeInfo>,
#[serde(skip)]
pub config: Option<ProjectConfig>,
#[serde(skip)]
pub expanded: bool,
#[serde(skip)]
pub missing: bool,
}
#[derive(Debug, Clone, Default)]
pub struct ProjectConfig {
pub post_create: Option<String>,
pub copy_includes: Vec<String>,
pub copy_excludes: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SessionInfo {
pub name: String, pub display_name: String, pub has_activity: bool, #[serde(skip)]
pub pane_capture: Option<String>,
#[serde(skip)]
pub last_activity: Option<std::time::Instant>,
pub foreground: ForegroundKind, #[serde(skip)]
pub is_running_wsx: bool, #[serde(skip)]
pub muted: bool, }
#[derive(Debug, Clone, PartialEq, Serialize)]
pub enum FetchFailReason {
Auth, Timeout, Network, }
#[derive(Debug, Clone, Serialize)]
pub struct WorktreeInfo {
pub name: String,
pub branch: String,
pub path: PathBuf,
pub is_main: bool,
pub alias: Option<String>,
pub sessions: Vec<SessionInfo>,
#[serde(skip)]
pub expanded: bool,
pub git_info: Option<GitInfo>,
pub fetch_failed: bool,
pub fetch_fail_count: u32,
pub fetch_fail_reason: Option<FetchFailReason>,
#[serde(skip)]
pub last_fetched: Option<std::time::Instant>,
#[serde(skip)]
pub git_info_fetched_at: Option<std::time::Instant>,
}
impl Project {
pub fn branch_session_names(&self) -> HashMap<String, Vec<String>> {
self.worktrees
.iter()
.map(|wt| {
let sessions = wt.sessions.iter().map(|s| s.name.clone()).collect();
(wt.branch.clone(), sessions)
})
.collect()
}
}
impl WorktreeInfo {
pub fn display_name(&self) -> &str {
self.alias.as_deref().unwrap_or(&self.name)
}
pub fn session_slug(&self, project_name: &str) -> String {
canonical_session_slug(project_name, &self.path)
}
pub fn session_names(&self) -> Vec<String> {
self.sessions.iter().map(|s| s.name.clone()).collect()
}
}
fn sanitize_slug(raw: &str) -> String {
raw.replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-")
}
fn legacy_branch_slug(branch: &str) -> String {
sanitize_slug(&branch.replace('/', "-"))
}
pub fn canonical_session_slug(project_name: &str, worktree_path: &Path) -> String {
let dir_name = worktree_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| project_name.to_string());
let proj_prefix = format!("{}-", project_name);
let short_name = dir_name.strip_prefix(&proj_prefix).unwrap_or(&dir_name);
sanitize_slug(short_name)
}
pub fn session_display_name_from_tmux(
tmux_name: &str,
project_name: &str,
worktree_path: &Path,
branch: &str,
alias: Option<&str>,
) -> String {
let canonical = format!(
"{}-{}-",
project_name,
canonical_session_slug(project_name, worktree_path)
);
if let Some(rest) = tmux_name.strip_prefix(&canonical) {
return rest.to_string();
}
let legacy_branch = format!("{}-{}-", project_name, legacy_branch_slug(branch));
if let Some(rest) = tmux_name.strip_prefix(&legacy_branch) {
return rest.to_string();
}
if let Some(alias) = alias {
let legacy_alias = format!("{}-{}-", project_name, sanitize_slug(alias));
if let Some(rest) = tmux_name.strip_prefix(&legacy_alias) {
return rest.to_string();
}
}
if let Some(rest) = tmux_name.strip_prefix(&format!("{}-", project_name)) {
if let Some((_, display)) = rest.split_once('-') {
return display.to_string();
}
}
tmux_name.to_string()
}
#[cfg(test)]
mod tests {
use super::{canonical_session_slug, session_display_name_from_tmux};
use std::path::Path;
#[test]
fn canonical_slug_uses_worktree_dir_for_main() {
let slug = canonical_session_slug("wsx", Path::new("/tmp/wsx"));
assert_eq!(slug, "wsx");
}
#[test]
fn canonical_slug_strips_project_prefix_for_worktrees() {
let slug = canonical_session_slug("wsx", Path::new("/tmp/wsx-feature-auth"));
assert_eq!(slug, "feature-auth");
}
#[test]
fn display_name_parses_canonical_prefix() {
let display = session_display_name_from_tmux(
"wsx-wsx-agent",
"wsx",
Path::new("/tmp/wsx"),
"main",
None,
);
assert_eq!(display, "agent");
}
#[test]
fn display_name_parses_legacy_branch_prefix() {
let display = session_display_name_from_tmux(
"wsx-main-agent",
"wsx",
Path::new("/tmp/wsx"),
"main",
None,
);
assert_eq!(display, "agent");
}
#[test]
fn display_name_parses_legacy_alias_prefix() {
let display = session_display_name_from_tmux(
"wsx-auth-agent",
"wsx",
Path::new("/tmp/wsx-feature-auth"),
"feature/auth",
Some("auth"),
);
assert_eq!(display, "agent");
}
#[test]
fn display_name_falls_back_to_project_slug_pattern() {
let display = session_display_name_from_tmux(
"wsx-oldslug-agent",
"wsx",
Path::new("/tmp/wsx-feature-auth"),
"feature/auth",
None,
);
assert_eq!(display, "agent");
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct GitInfo {
pub recent_commits: Vec<CommitSummary>,
pub modified_files: Vec<String>,
pub ahead: usize,
pub behind: usize,
pub remote_branch: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct CommitSummary {
pub hash: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum FlatEntry {
Project {
idx: usize,
},
Worktree {
project_idx: usize,
worktree_idx: usize,
},
Session {
project_idx: usize,
worktree_idx: usize,
session_idx: usize,
},
}
#[allow(dead_code)]
pub fn flatten_tree(workspace: &WorkspaceState) -> Vec<FlatEntry> {
let mut result = Vec::new();
for (pi, project) in workspace.projects.iter().enumerate() {
result.push(FlatEntry::Project { idx: pi });
if project.expanded {
for (wi, wt) in project.worktrees.iter().enumerate() {
result.push(FlatEntry::Worktree {
project_idx: pi,
worktree_idx: wi,
});
if wt.expanded {
for (si, _) in wt.sessions.iter().enumerate() {
result.push(FlatEntry::Session {
project_idx: pi,
worktree_idx: wi,
session_idx: si,
});
}
}
}
}
}
result
}
pub fn flatten_tree_filtered(
workspace: &WorkspaceState,
visible: &HashSet<usize>,
) -> Vec<FlatEntry> {
let mut result = Vec::new();
for (pi, project) in workspace.projects.iter().enumerate() {
if !visible.contains(&pi) {
continue;
}
result.push(FlatEntry::Project { idx: pi });
if project.expanded {
for (wi, wt) in project.worktrees.iter().enumerate() {
result.push(FlatEntry::Worktree {
project_idx: pi,
worktree_idx: wi,
});
if wt.expanded {
for (si, _) in wt.sessions.iter().enumerate() {
result.push(FlatEntry::Session {
project_idx: pi,
worktree_idx: wi,
session_idx: si,
});
}
}
}
}
}
result
}
#[derive(Debug, Clone, PartialEq)]
pub enum Selection {
Project(usize),
Worktree(usize, usize),
Session(usize, usize, usize),
None,
}
impl WorkspaceState {
pub fn empty() -> Self {
Self {
projects: Vec::new(),
}
}
pub fn worktree(&self, pi: usize, wi: usize) -> Option<&WorktreeInfo> {
self.projects.get(pi)?.worktrees.get(wi)
}
pub fn worktree_mut(&mut self, pi: usize, wi: usize) -> Option<&mut WorktreeInfo> {
self.projects.get_mut(pi)?.worktrees.get_mut(wi)
}
pub fn session(&self, pi: usize, wi: usize, si: usize) -> Option<&SessionInfo> {
self.projects.get(pi)?.worktrees.get(wi)?.sessions.get(si)
}
pub fn session_mut(&mut self, pi: usize, wi: usize, si: usize) -> Option<&mut SessionInfo> {
self.projects
.get_mut(pi)?
.worktrees
.get_mut(wi)?
.sessions
.get_mut(si)
}
pub fn get_selection(&self, flat_idx: usize, flat: &[FlatEntry]) -> Selection {
match flat.get(flat_idx) {
Some(FlatEntry::Project { idx }) => Selection::Project(*idx),
Some(FlatEntry::Worktree {
project_idx,
worktree_idx,
}) => Selection::Worktree(*project_idx, *worktree_idx),
Some(FlatEntry::Session {
project_idx,
worktree_idx,
session_idx,
}) => Selection::Session(*project_idx, *worktree_idx, *session_idx),
None => Selection::None,
}
}
}