1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use serde::Serialize;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
10pub enum ForegroundKind {
11 #[default]
12 Unknown,
13 Shell,
14 PassiveViewer,
15 Runtime,
16 Agent,
17 InteractiveApp,
18}
19
20#[derive(Debug, Clone, Serialize)]
21pub struct WorkspaceState {
22 pub projects: Vec<Project>,
23}
24
25#[derive(Debug, Clone, Serialize)]
26pub struct Project {
27 pub name: String,
28 pub path: PathBuf,
29 pub default_branch: String,
30 pub worktrees: Vec<WorktreeInfo>,
31 #[serde(skip)]
32 pub config: Option<ProjectConfig>,
33 #[serde(skip)]
34 pub expanded: bool,
35 #[serde(skip)]
36 pub missing: bool,
37}
38
39#[derive(Debug, Clone, Default)]
40pub struct ProjectConfig {
41 pub post_create: Option<String>,
42 pub copy_includes: Vec<String>,
43 pub copy_excludes: Vec<String>,
44}
45
46#[derive(Debug, Clone, Serialize)]
47pub struct SessionInfo {
48 pub name: String, pub display_name: String, pub has_activity: bool, #[serde(skip)]
52 pub pane_capture: Option<String>,
53 #[serde(skip)]
54 pub last_activity: Option<std::time::Instant>,
55 pub foreground: ForegroundKind, #[serde(skip)]
57 pub is_running_wsx: bool, #[serde(skip)]
59 pub muted: bool, }
61
62#[derive(Debug, Clone, PartialEq, Serialize)]
63pub enum FetchFailReason {
64 Auth, Timeout, Network, }
68
69#[derive(Debug, Clone, Serialize)]
70pub struct WorktreeInfo {
71 pub name: String,
72 pub branch: String,
73 pub path: PathBuf,
74 pub is_main: bool,
75 pub alias: Option<String>,
76 pub sessions: Vec<SessionInfo>,
77 #[serde(skip)]
78 pub expanded: bool,
79 pub git_info: Option<GitInfo>,
80 pub fetch_failed: bool,
81 pub fetch_fail_count: u32,
82 pub fetch_fail_reason: Option<FetchFailReason>,
83 #[serde(skip)]
84 pub last_fetched: Option<std::time::Instant>,
85 #[serde(skip)]
86 pub git_info_fetched_at: Option<std::time::Instant>,
87}
88
89impl Project {
90 pub fn branch_session_names(&self) -> HashMap<String, Vec<String>> {
92 self.worktrees
93 .iter()
94 .map(|wt| {
95 let sessions = wt.sessions.iter().map(|s| s.name.clone()).collect();
96 (wt.branch.clone(), sessions)
97 })
98 .collect()
99 }
100}
101
102impl WorktreeInfo {
103 pub fn display_name(&self) -> &str {
104 self.alias.as_deref().unwrap_or(&self.name)
105 }
106
107 pub fn session_slug(&self, project_name: &str) -> String {
108 canonical_session_slug(project_name, &self.path)
109 }
110
111 pub fn session_names(&self) -> Vec<String> {
112 self.sessions.iter().map(|s| s.name.clone()).collect()
113 }
114}
115
116fn sanitize_slug(raw: &str) -> String {
117 raw.replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-")
118}
119
120fn legacy_branch_slug(branch: &str) -> String {
121 sanitize_slug(&branch.replace('/', "-"))
122}
123
124pub fn canonical_session_slug(project_name: &str, worktree_path: &Path) -> String {
125 let dir_name = worktree_path
126 .file_name()
127 .map(|n| n.to_string_lossy().to_string())
128 .unwrap_or_else(|| project_name.to_string());
129 let proj_prefix = format!("{}-", project_name);
130 let short_name = dir_name.strip_prefix(&proj_prefix).unwrap_or(&dir_name);
131 sanitize_slug(short_name)
132}
133
134pub fn session_display_name_from_tmux(
135 tmux_name: &str,
136 project_name: &str,
137 worktree_path: &Path,
138 branch: &str,
139 alias: Option<&str>,
140) -> String {
141 let canonical = format!(
142 "{}-{}-",
143 project_name,
144 canonical_session_slug(project_name, worktree_path)
145 );
146 if let Some(rest) = tmux_name.strip_prefix(&canonical) {
147 return rest.to_string();
148 }
149
150 let legacy_branch = format!("{}-{}-", project_name, legacy_branch_slug(branch));
152 if let Some(rest) = tmux_name.strip_prefix(&legacy_branch) {
153 return rest.to_string();
154 }
155
156 if let Some(alias) = alias {
157 let legacy_alias = format!("{}-{}-", project_name, sanitize_slug(alias));
158 if let Some(rest) = tmux_name.strip_prefix(&legacy_alias) {
159 return rest.to_string();
160 }
161 }
162
163 if let Some(rest) = tmux_name.strip_prefix(&format!("{}-", project_name)) {
165 if let Some((_, display)) = rest.split_once('-') {
166 return display.to_string();
167 }
168 }
169
170 tmux_name.to_string()
171}
172
173#[cfg(test)]
174mod tests {
175 use super::{canonical_session_slug, session_display_name_from_tmux};
176 use std::path::Path;
177
178 #[test]
179 fn canonical_slug_uses_worktree_dir_for_main() {
180 let slug = canonical_session_slug("wsx", Path::new("/tmp/wsx"));
181 assert_eq!(slug, "wsx");
182 }
183
184 #[test]
185 fn canonical_slug_strips_project_prefix_for_worktrees() {
186 let slug = canonical_session_slug("wsx", Path::new("/tmp/wsx-feature-auth"));
187 assert_eq!(slug, "feature-auth");
188 }
189
190 #[test]
191 fn display_name_parses_canonical_prefix() {
192 let display = session_display_name_from_tmux(
193 "wsx-wsx-agent",
194 "wsx",
195 Path::new("/tmp/wsx"),
196 "main",
197 None,
198 );
199 assert_eq!(display, "agent");
200 }
201
202 #[test]
203 fn display_name_parses_legacy_branch_prefix() {
204 let display = session_display_name_from_tmux(
205 "wsx-main-agent",
206 "wsx",
207 Path::new("/tmp/wsx"),
208 "main",
209 None,
210 );
211 assert_eq!(display, "agent");
212 }
213
214 #[test]
215 fn display_name_parses_legacy_alias_prefix() {
216 let display = session_display_name_from_tmux(
217 "wsx-auth-agent",
218 "wsx",
219 Path::new("/tmp/wsx-feature-auth"),
220 "feature/auth",
221 Some("auth"),
222 );
223 assert_eq!(display, "agent");
224 }
225
226 #[test]
227 fn display_name_falls_back_to_project_slug_pattern() {
228 let display = session_display_name_from_tmux(
229 "wsx-oldslug-agent",
230 "wsx",
231 Path::new("/tmp/wsx-feature-auth"),
232 "feature/auth",
233 None,
234 );
235 assert_eq!(display, "agent");
236 }
237}
238
239#[derive(Debug, Clone, PartialEq, Serialize)]
240pub struct GitInfo {
241 pub recent_commits: Vec<CommitSummary>,
242 pub modified_files: Vec<String>,
243 pub ahead: usize,
244 pub behind: usize,
245 pub remote_branch: Option<String>,
246}
247
248#[derive(Debug, Clone, PartialEq, Serialize)]
249pub struct CommitSummary {
250 pub hash: String,
251 pub message: String,
252}
253
254#[derive(Debug, Clone, PartialEq)]
256pub enum FlatEntry {
257 Project {
258 idx: usize,
259 },
260 Worktree {
261 project_idx: usize,
262 worktree_idx: usize,
263 },
264 Session {
265 project_idx: usize,
266 worktree_idx: usize,
267 session_idx: usize,
268 },
269}
270
271#[allow(dead_code)]
273pub fn flatten_tree(workspace: &WorkspaceState) -> Vec<FlatEntry> {
274 let mut result = Vec::new();
275 for (pi, project) in workspace.projects.iter().enumerate() {
276 result.push(FlatEntry::Project { idx: pi });
277 if project.expanded {
278 for (wi, wt) in project.worktrees.iter().enumerate() {
279 result.push(FlatEntry::Worktree {
280 project_idx: pi,
281 worktree_idx: wi,
282 });
283 if wt.expanded {
284 for (si, _) in wt.sessions.iter().enumerate() {
285 result.push(FlatEntry::Session {
286 project_idx: pi,
287 worktree_idx: wi,
288 session_idx: si,
289 });
290 }
291 }
292 }
293 }
294 }
295 result
296}
297
298pub fn flatten_tree_filtered(
300 workspace: &WorkspaceState,
301 visible: &HashSet<usize>,
302) -> Vec<FlatEntry> {
303 let mut result = Vec::new();
304 for (pi, project) in workspace.projects.iter().enumerate() {
305 if !visible.contains(&pi) {
306 continue;
307 }
308 result.push(FlatEntry::Project { idx: pi });
309 if project.expanded {
310 for (wi, wt) in project.worktrees.iter().enumerate() {
311 result.push(FlatEntry::Worktree {
312 project_idx: pi,
313 worktree_idx: wi,
314 });
315 if wt.expanded {
316 for (si, _) in wt.sessions.iter().enumerate() {
317 result.push(FlatEntry::Session {
318 project_idx: pi,
319 worktree_idx: wi,
320 session_idx: si,
321 });
322 }
323 }
324 }
325 }
326 }
327 result
328}
329
330#[derive(Debug, Clone, PartialEq)]
332pub enum Selection {
333 Project(usize),
334 Worktree(usize, usize),
335 Session(usize, usize, usize),
336 None,
337}
338
339impl WorkspaceState {
340 pub fn empty() -> Self {
341 Self {
342 projects: Vec::new(),
343 }
344 }
345
346 pub fn worktree(&self, pi: usize, wi: usize) -> Option<&WorktreeInfo> {
347 self.projects.get(pi)?.worktrees.get(wi)
348 }
349
350 pub fn worktree_mut(&mut self, pi: usize, wi: usize) -> Option<&mut WorktreeInfo> {
351 self.projects.get_mut(pi)?.worktrees.get_mut(wi)
352 }
353
354 pub fn session(&self, pi: usize, wi: usize, si: usize) -> Option<&SessionInfo> {
355 self.projects.get(pi)?.worktrees.get(wi)?.sessions.get(si)
356 }
357
358 pub fn session_mut(&mut self, pi: usize, wi: usize, si: usize) -> Option<&mut SessionInfo> {
359 self.projects
360 .get_mut(pi)?
361 .worktrees
362 .get_mut(wi)?
363 .sessions
364 .get_mut(si)
365 }
366
367 pub fn get_selection(&self, flat_idx: usize, flat: &[FlatEntry]) -> Selection {
369 match flat.get(flat_idx) {
370 Some(FlatEntry::Project { idx }) => Selection::Project(*idx),
371 Some(FlatEntry::Worktree {
372 project_idx,
373 worktree_idx,
374 }) => Selection::Worktree(*project_idx, *worktree_idx),
375 Some(FlatEntry::Session {
376 project_idx,
377 worktree_idx,
378 session_idx,
379 }) => Selection::Session(*project_idx, *worktree_idx, *session_idx),
380 None => Selection::None,
381 }
382 }
383}