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 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,         // full tmux session name
49    pub display_name: String, // shown in UI (strips wt_slug prefix)
50    pub has_activity: bool,   // tmux bell/alert flag
51    #[serde(skip)]
52    pub pane_capture: Option<String>,
53    #[serde(skip)]
54    pub last_activity: Option<std::time::Instant>,
55    pub foreground: ForegroundKind, // raw process classification — see tmux::monitor
56    #[serde(skip)]
57    pub is_running_wsx: bool, // foreground process is wsx — suppresses capture preview
58    #[serde(skip)]
59    pub muted: bool, // user silenced — no activity updates, shown as ⊘
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize)]
63pub enum FetchFailReason {
64    Auth,    // "Authentication failed", "Permission denied", "could not read Username"
65    Timeout, // killed after 10s
66    Network, // generic / other failure
67}
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    /// Maps branch name -> list of tmux session names for all worktrees.
91    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    // Backward compatibility: older builds prefixed by branch/alias slug.
151    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    // Last-resort compatibility for historical `{project}-{any_slug}-{display}` names.
164    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/// Flat tree entry for rendering and 3-level navigation.
255#[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/// Flatten workspace into visible tree entries based on expand state.
272#[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
298/// Like `flatten_tree` but skips projects whose index is not in `visible`.
299pub 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/// What is currently focused.
331#[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    /// Resolve flat index to Selection using a pre-computed flat slice.
368    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}