Skip to main content

wsx_core/model/
workspace.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use serde::Serialize;
5
6/// Foreground process class for a tmux session, classified by `tmux::monitor`.
7/// "Running" (Active state) is decided downstream in `session_state` — this
8/// enum stays a raw input.
9#[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,         // full tmux session name
55    pub display_name: String, // shown in UI (strips wt_slug prefix)
56    pub has_activity: bool,   // tmux bell/alert flag
57    #[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>, // normalized bounded tail used for semantic motion
63    #[serde(skip)]
64    pub tmux_activity_ts: u64, // raw tmux window activity timestamp for capture gating
65    pub foreground: ForegroundKind, // raw process classification — see tmux::monitor
66    #[serde(skip)]
67    pub is_running_wsx: bool, // foreground process is wsx — suppresses capture preview
68    #[serde(skip)]
69    pub muted: bool, // user silenced — no activity updates, shown as ⊘
70}
71
72#[derive(Debug, Clone, PartialEq, Serialize)]
73pub enum FetchFailReason {
74    Auth,    // "Authentication failed", "Permission denied", "could not read Username"
75    Timeout, // killed after 10s
76    Network, // generic / other failure
77}
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    /// Maps branch name -> list of tmux session names for all worktrees.
101    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    // Backward compatibility: older builds prefixed by branch/alias slug.
161    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    // Last-resort compatibility for historical `{project}-{any_slug}-{display}` names.
174    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/// Flat tree entry for rendering and 3-level navigation.
265#[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/// Flatten workspace into visible tree entries based on expand state.
289#[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
326/// Like `flatten_tree` but skips projects whose index is not in `visible`.
327pub 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/// What is currently focused.
370#[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    /// Resolve flat index to Selection using a pre-computed flat slice.
409    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}