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 routines: Vec<asched_core::routine::ipc::RoutineView>,
33 #[serde(skip)]
34 pub routine_revision: u64,
35 #[serde(skip)]
36 pub routines_expanded: bool,
37 #[serde(skip)]
38 pub config: Option<ProjectConfig>,
39 #[serde(skip)]
40 pub expanded: bool,
41 #[serde(skip)]
42 pub missing: bool,
43}
44
45#[derive(Debug, Clone, Default)]
46pub struct ProjectConfig {
47 pub post_create: Option<String>,
48 pub copy_includes: Vec<String>,
49 pub copy_excludes: Vec<String>,
50}
51
52#[derive(Debug, Clone, Serialize)]
53pub struct SessionInfo {
54 pub name: String, pub display_name: String, pub has_activity: bool, #[serde(skip)]
58 pub pane_capture: Option<String>,
59 #[serde(skip)]
60 pub last_activity: Option<std::time::Instant>,
61 #[serde(skip)]
62 pub agent_tail: Option<String>, #[serde(skip)]
64 pub tmux_activity_ts: u64, pub foreground: ForegroundKind, #[serde(skip)]
67 pub is_running_wsx: bool, #[serde(skip)]
69 pub muted: bool, }
71
72#[derive(Debug, Clone, PartialEq, Serialize)]
73pub enum FetchFailReason {
74 Auth, Timeout, Network, }
78
79#[derive(Debug, Clone, Serialize)]
80pub struct WorktreeInfo {
81 pub name: String,
82 pub branch: String,
83 pub path: PathBuf,
84 pub is_main: bool,
85 pub alias: Option<String>,
86 pub sessions: Vec<SessionInfo>,
87 #[serde(skip)]
88 pub expanded: bool,
89 pub git_info: Option<GitInfo>,
90 pub fetch_failed: bool,
91 pub fetch_fail_count: u32,
92 pub fetch_fail_reason: Option<FetchFailReason>,
93 #[serde(skip)]
94 pub last_fetched: Option<std::time::Instant>,
95 #[serde(skip)]
96 pub git_info_fetched_at: Option<std::time::Instant>,
97}
98
99impl Project {
100 pub fn branch_session_names(&self) -> HashMap<String, Vec<String>> {
102 self.worktrees
103 .iter()
104 .map(|wt| {
105 let sessions = wt.sessions.iter().map(|s| s.name.clone()).collect();
106 (wt.branch.clone(), sessions)
107 })
108 .collect()
109 }
110}
111
112impl WorktreeInfo {
113 pub fn display_name(&self) -> &str {
114 self.alias.as_deref().unwrap_or(&self.name)
115 }
116
117 pub fn session_slug(&self, project_name: &str) -> String {
118 canonical_session_slug(project_name, &self.path)
119 }
120
121 pub fn session_names(&self) -> Vec<String> {
122 self.sessions.iter().map(|s| s.name.clone()).collect()
123 }
124}
125
126fn sanitize_slug(raw: &str) -> String {
127 raw.replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-")
128}
129
130fn legacy_branch_slug(branch: &str) -> String {
131 sanitize_slug(&branch.replace('/', "-"))
132}
133
134pub fn canonical_session_slug(project_name: &str, worktree_path: &Path) -> String {
135 let dir_name = worktree_path
136 .file_name()
137 .map(|n| n.to_string_lossy().to_string())
138 .unwrap_or_else(|| project_name.to_string());
139 let proj_prefix = format!("{}-", project_name);
140 let short_name = dir_name.strip_prefix(&proj_prefix).unwrap_or(&dir_name);
141 sanitize_slug(short_name)
142}
143
144pub fn session_display_name_from_tmux(
145 tmux_name: &str,
146 project_name: &str,
147 worktree_path: &Path,
148 branch: &str,
149 alias: Option<&str>,
150) -> String {
151 let canonical = format!(
152 "{}-{}-",
153 project_name,
154 canonical_session_slug(project_name, worktree_path)
155 );
156 if let Some(rest) = tmux_name.strip_prefix(&canonical) {
157 return rest.to_string();
158 }
159
160 let legacy_branch = format!("{}-{}-", project_name, legacy_branch_slug(branch));
162 if let Some(rest) = tmux_name.strip_prefix(&legacy_branch) {
163 return rest.to_string();
164 }
165
166 if let Some(alias) = alias {
167 let legacy_alias = format!("{}-{}-", project_name, sanitize_slug(alias));
168 if let Some(rest) = tmux_name.strip_prefix(&legacy_alias) {
169 return rest.to_string();
170 }
171 }
172
173 if let Some(rest) = tmux_name.strip_prefix(&format!("{}-", project_name)) {
175 if let Some((_, display)) = rest.split_once('-') {
176 return display.to_string();
177 }
178 }
179
180 tmux_name.to_string()
181}
182
183#[cfg(test)]
184mod tests {
185 use super::{canonical_session_slug, session_display_name_from_tmux};
186 use std::path::Path;
187
188 #[test]
189 fn canonical_slug_uses_worktree_dir_for_main() {
190 let slug = canonical_session_slug("wsx", Path::new("/tmp/wsx"));
191 assert_eq!(slug, "wsx");
192 }
193
194 #[test]
195 fn canonical_slug_strips_project_prefix_for_worktrees() {
196 let slug = canonical_session_slug("wsx", Path::new("/tmp/wsx-feature-auth"));
197 assert_eq!(slug, "feature-auth");
198 }
199
200 #[test]
201 fn display_name_parses_canonical_prefix() {
202 let display = session_display_name_from_tmux(
203 "wsx-wsx-agent",
204 "wsx",
205 Path::new("/tmp/wsx"),
206 "main",
207 None,
208 );
209 assert_eq!(display, "agent");
210 }
211
212 #[test]
213 fn display_name_parses_legacy_branch_prefix() {
214 let display = session_display_name_from_tmux(
215 "wsx-main-agent",
216 "wsx",
217 Path::new("/tmp/wsx"),
218 "main",
219 None,
220 );
221 assert_eq!(display, "agent");
222 }
223
224 #[test]
225 fn display_name_parses_legacy_alias_prefix() {
226 let display = session_display_name_from_tmux(
227 "wsx-auth-agent",
228 "wsx",
229 Path::new("/tmp/wsx-feature-auth"),
230 "feature/auth",
231 Some("auth"),
232 );
233 assert_eq!(display, "agent");
234 }
235
236 #[test]
237 fn display_name_falls_back_to_project_slug_pattern() {
238 let display = session_display_name_from_tmux(
239 "wsx-oldslug-agent",
240 "wsx",
241 Path::new("/tmp/wsx-feature-auth"),
242 "feature/auth",
243 None,
244 );
245 assert_eq!(display, "agent");
246 }
247}
248
249#[derive(Debug, Clone, PartialEq, Serialize)]
250pub struct GitInfo {
251 pub recent_commits: Vec<CommitSummary>,
252 pub modified_files: Vec<String>,
253 pub ahead: usize,
254 pub behind: usize,
255 pub remote_branch: Option<String>,
256}
257
258#[derive(Debug, Clone, PartialEq, Serialize)]
259pub struct CommitSummary {
260 pub hash: String,
261 pub message: String,
262}
263
264#[derive(Debug, Clone, PartialEq)]
266pub enum FlatEntry {
267 Project {
268 idx: usize,
269 },
270 Worktree {
271 project_idx: usize,
272 worktree_idx: usize,
273 },
274 Session {
275 project_idx: usize,
276 worktree_idx: usize,
277 session_idx: usize,
278 },
279 RoutinesHeader {
280 project_idx: usize,
281 },
282 Routine {
283 project_idx: usize,
284 routine_idx: usize,
285 },
286}
287
288#[allow(dead_code)]
290pub fn flatten_tree(workspace: &WorkspaceState) -> Vec<FlatEntry> {
291 let mut result = Vec::new();
292 for (pi, project) in workspace.projects.iter().enumerate() {
293 result.push(FlatEntry::Project { idx: pi });
294 if project.expanded {
295 for (wi, wt) in project.worktrees.iter().enumerate() {
296 result.push(FlatEntry::Worktree {
297 project_idx: pi,
298 worktree_idx: wi,
299 });
300 if wt.expanded {
301 for (si, _) in wt.sessions.iter().enumerate() {
302 result.push(FlatEntry::Session {
303 project_idx: pi,
304 worktree_idx: wi,
305 session_idx: si,
306 });
307 }
308 }
309 }
310 if !project.routines.is_empty() {
311 result.push(FlatEntry::RoutinesHeader { project_idx: pi });
312 if project.routines_expanded {
313 for (ri, _) in project.routines.iter().enumerate() {
314 result.push(FlatEntry::Routine {
315 project_idx: pi,
316 routine_idx: ri,
317 });
318 }
319 }
320 }
321 }
322 }
323 result
324}
325
326pub fn flatten_tree_filtered(
328 workspace: &WorkspaceState,
329 visible: &HashSet<usize>,
330) -> Vec<FlatEntry> {
331 let mut result = Vec::new();
332 for (pi, project) in workspace.projects.iter().enumerate() {
333 if !visible.contains(&pi) {
334 continue;
335 }
336 result.push(FlatEntry::Project { idx: pi });
337 if project.expanded {
338 for (wi, wt) in project.worktrees.iter().enumerate() {
339 result.push(FlatEntry::Worktree {
340 project_idx: pi,
341 worktree_idx: wi,
342 });
343 if wt.expanded {
344 for (si, _) in wt.sessions.iter().enumerate() {
345 result.push(FlatEntry::Session {
346 project_idx: pi,
347 worktree_idx: wi,
348 session_idx: si,
349 });
350 }
351 }
352 }
353 if !project.routines.is_empty() {
354 result.push(FlatEntry::RoutinesHeader { project_idx: pi });
355 if project.routines_expanded {
356 for (ri, _) in project.routines.iter().enumerate() {
357 result.push(FlatEntry::Routine {
358 project_idx: pi,
359 routine_idx: ri,
360 });
361 }
362 }
363 }
364 }
365 }
366 result
367}
368
369#[derive(Debug, Clone, PartialEq)]
371pub enum Selection {
372 Project(usize),
373 Worktree(usize, usize),
374 Session(usize, usize, usize),
375 RoutinesHeader(usize),
376 Routine(usize, usize),
377 None,
378}
379
380impl WorkspaceState {
381 pub fn empty() -> Self {
382 Self {
383 projects: Vec::new(),
384 }
385 }
386
387 pub fn worktree(&self, pi: usize, wi: usize) -> Option<&WorktreeInfo> {
388 self.projects.get(pi)?.worktrees.get(wi)
389 }
390
391 pub fn worktree_mut(&mut self, pi: usize, wi: usize) -> Option<&mut WorktreeInfo> {
392 self.projects.get_mut(pi)?.worktrees.get_mut(wi)
393 }
394
395 pub fn session(&self, pi: usize, wi: usize, si: usize) -> Option<&SessionInfo> {
396 self.projects.get(pi)?.worktrees.get(wi)?.sessions.get(si)
397 }
398
399 pub fn session_mut(&mut self, pi: usize, wi: usize, si: usize) -> Option<&mut SessionInfo> {
400 self.projects
401 .get_mut(pi)?
402 .worktrees
403 .get_mut(wi)?
404 .sessions
405 .get_mut(si)
406 }
407
408 pub fn get_selection(&self, flat_idx: usize, flat: &[FlatEntry]) -> Selection {
410 match flat.get(flat_idx) {
411 Some(FlatEntry::Project { idx }) => Selection::Project(*idx),
412 Some(FlatEntry::Worktree {
413 project_idx,
414 worktree_idx,
415 }) => Selection::Worktree(*project_idx, *worktree_idx),
416 Some(FlatEntry::Session {
417 project_idx,
418 worktree_idx,
419 session_idx,
420 }) => Selection::Session(*project_idx, *worktree_idx, *session_idx),
421 Some(FlatEntry::RoutinesHeader { project_idx }) => {
422 Selection::RoutinesHeader(*project_idx)
423 }
424 Some(FlatEntry::Routine {
425 project_idx,
426 routine_idx,
427 }) => Selection::Routine(*project_idx, *routine_idx),
428 None => Selection::None,
429 }
430 }
431}