Skip to main content

wsx_core/model/
workspace.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use crate::runtime::{AgentState, PaneId, PaneLayout, SessionId, TerminalId};
5use serde::Serialize;
6
7#[derive(Debug, Clone, Serialize)]
8pub struct WorkspaceState {
9    pub projects: Vec<Project>,
10}
11
12#[derive(Debug, Clone, Serialize)]
13pub struct Project {
14    pub name: String,
15    pub path: PathBuf,
16    pub default_branch: String,
17    pub last_agent_active_unix_ms: Option<u64>,
18    pub last_terminal_active_unix_ms: Option<u64>,
19    pub worktrees: Vec<WorktreeInfo>,
20    #[serde(skip)]
21    pub routines: Vec<asched_core::routine::ipc::RoutineView>,
22    #[serde(skip)]
23    pub routine_revision: u64,
24    #[serde(skip)]
25    pub routines_expanded: bool,
26    #[serde(skip)]
27    pub config: Option<ProjectConfig>,
28    #[serde(skip)]
29    pub expanded: bool,
30    #[serde(skip)]
31    pub missing: bool,
32}
33
34#[derive(Debug, Clone, Default)]
35pub struct ProjectConfig {
36    pub post_create: Option<String>,
37    pub copy_includes: Vec<String>,
38    pub copy_excludes: Vec<String>,
39    /// Explicit Git subtree roots relative to the project worktree.
40    pub git_subtrees: Vec<PathBuf>,
41    /// Migration or parse feedback for the TUI; never affects worktree behavior.
42    pub notice: Option<String>,
43}
44
45#[derive(Debug, Clone, Serialize)]
46pub struct PaneInfo {
47    pub pane_id: PaneId,
48    pub terminal_id: TerminalId,
49    pub label: String,
50    pub agent: Option<String>,
51    pub agent_status: AgentState,
52    pub revision: u64,
53    pub exited: bool,
54    pub listening_ports: Vec<u16>,
55    pub foreground_job: bool,
56    /// This exact provider outcome revision was acknowledged by explicit UI interaction.
57    #[serde(skip)]
58    pub outcome_acknowledged: bool,
59}
60
61#[derive(Debug, Clone, Serialize)]
62pub struct SessionInfo {
63    pub session_id: SessionId,
64    pub pane_id: PaneId,
65    pub terminal_id: TerminalId,
66    /// Provider label reported by the pane's normalized agent adapter.
67    pub agent: Option<String>,
68    pub display_name: String,
69    pub agent_status: AgentState,
70    pub revision: u64,
71    pub layout: PaneLayout,
72    pub panes: Vec<PaneInfo>,
73    #[serde(skip)]
74    pub muted: bool,
75    /// This exact provider outcome revision was acknowledged by explicit UI interaction.
76    #[serde(skip)]
77    pub outcome_acknowledged: bool,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize)]
81pub enum FetchFailReason {
82    Auth,    // "Authentication failed", "Permission denied", "could not read Username"
83    Timeout, // killed after 10s
84    Network, // generic / other failure
85}
86
87impl SessionInfo {
88    pub fn has_foreground_job(&self) -> bool {
89        self.panes.iter().any(|pane| pane.foreground_job)
90    }
91
92    pub fn is_agentic(&self) -> bool {
93        self.agent.is_some() || self.panes.iter().any(|pane| pane.agent.is_some())
94    }
95
96    pub fn listening_ports(&self) -> Vec<u16> {
97        let mut ports = self
98            .panes
99            .iter()
100            .flat_map(|pane| pane.listening_ports.iter().copied())
101            .collect::<Vec<_>>();
102        ports.sort_unstable();
103        ports.dedup();
104        ports
105    }
106}
107
108#[derive(Debug, Clone, Serialize)]
109pub struct WorktreeInfo {
110    pub name: String,
111    pub branch: String,
112    pub path: PathBuf,
113    pub is_main: bool,
114    pub alias: Option<String>,
115    pub sessions: Vec<SessionInfo>,
116    #[serde(skip)]
117    pub expanded: bool,
118    pub git_info: Option<GitInfo>,
119    pub fetch_failed: bool,
120    pub fetch_fail_count: u32,
121    pub fetch_fail_reason: Option<FetchFailReason>,
122    #[serde(skip)]
123    pub last_fetched: Option<std::time::Instant>,
124    #[serde(skip)]
125    pub git_info_fetched_at: Option<std::time::Instant>,
126}
127
128impl WorktreeInfo {
129    pub fn listening_ports(&self) -> Vec<u16> {
130        let mut ports = self
131            .sessions
132            .iter()
133            .flat_map(SessionInfo::listening_ports)
134            .collect::<Vec<_>>();
135        ports.sort_unstable();
136        ports.dedup();
137        ports
138    }
139
140    pub fn display_name(&self) -> &str {
141        self.alias.as_deref().unwrap_or(&self.name)
142    }
143
144    pub fn session_slug(&self, project_name: &str) -> String {
145        canonical_session_slug(project_name, &self.path)
146    }
147}
148
149fn sanitize_slug(raw: &str) -> String {
150    raw.replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-")
151}
152
153pub fn canonical_session_slug(project_name: &str, worktree_path: &Path) -> String {
154    let dir_name = worktree_path
155        .file_name()
156        .map(|n| n.to_string_lossy().to_string())
157        .unwrap_or_else(|| project_name.to_string());
158    let proj_prefix = format!("{}-", project_name);
159    let short_name = dir_name.strip_prefix(&proj_prefix).unwrap_or(&dir_name);
160    sanitize_slug(short_name)
161}
162
163#[cfg(test)]
164mod tests {
165    use super::canonical_session_slug;
166    use std::path::Path;
167
168    #[test]
169    fn canonical_slug_uses_human_worktree_identity() {
170        assert_eq!(canonical_session_slug("wsx", Path::new("/tmp/wsx")), "wsx");
171        assert_eq!(
172            canonical_session_slug("wsx", Path::new("/tmp/wsx-feature-auth")),
173            "feature-auth"
174        );
175    }
176}
177
178#[derive(Debug, Clone, PartialEq, Serialize)]
179pub struct GitInfo {
180    pub recent_commits: Vec<CommitSummary>,
181    pub modified_files: Vec<String>,
182    /// `None` means Git could not inspect configured submodules.
183    pub submodules: Option<Vec<SubmoduleInfo>>,
184    pub subtrees: Vec<SubtreeInfo>,
185    pub ahead: usize,
186    pub behind: usize,
187    pub remote_branch: Option<String>,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
191#[serde(rename_all = "snake_case")]
192pub enum SubmoduleCommitState {
193    InSync,
194    CommitChanged,
195    Uninitialized,
196    Conflict,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
200pub struct SubmoduleInfo {
201    pub path: String,
202    pub commit_state: SubmoduleCommitState,
203    pub modified_content: bool,
204    pub untracked_content: bool,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
208pub struct SubtreeInfo {
209    pub path: String,
210    pub modified_files: Vec<String>,
211}
212
213#[derive(Debug, Clone, PartialEq, Serialize)]
214pub struct CommitSummary {
215    pub hash: String,
216    pub message: String,
217}
218
219/// Flat tree entry for rendering and 3-level navigation.
220#[derive(Debug, Clone, PartialEq)]
221pub enum FlatEntry {
222    Project {
223        idx: usize,
224    },
225    Worktree {
226        project_idx: usize,
227        worktree_idx: usize,
228    },
229    Session {
230        project_idx: usize,
231        worktree_idx: usize,
232        session_idx: usize,
233    },
234    Pane {
235        project_idx: usize,
236        worktree_idx: usize,
237        session_idx: usize,
238        pane_idx: usize,
239    },
240    RoutinesHeader {
241        project_idx: usize,
242    },
243    Routine {
244        project_idx: usize,
245        routine_idx: usize,
246    },
247}
248
249/// Flatten workspace into visible tree entries based on expand state.
250#[allow(dead_code)]
251pub fn flatten_tree(workspace: &WorkspaceState) -> Vec<FlatEntry> {
252    let mut result = Vec::new();
253    for (pi, project) in workspace.projects.iter().enumerate() {
254        result.push(FlatEntry::Project { idx: pi });
255        if project.expanded {
256            for (wi, wt) in project.worktrees.iter().enumerate() {
257                result.push(FlatEntry::Worktree {
258                    project_idx: pi,
259                    worktree_idx: wi,
260                });
261                if wt.expanded {
262                    for (si, session) in wt.sessions.iter().enumerate() {
263                        result.push(FlatEntry::Session {
264                            project_idx: pi,
265                            worktree_idx: wi,
266                            session_idx: si,
267                        });
268                        if session.panes.len() > 1 {
269                            for (pane_idx, _) in session.panes.iter().enumerate() {
270                                result.push(FlatEntry::Pane {
271                                    project_idx: pi,
272                                    worktree_idx: wi,
273                                    session_idx: si,
274                                    pane_idx,
275                                });
276                            }
277                        }
278                    }
279                }
280            }
281            if !project.routines.is_empty() {
282                result.push(FlatEntry::RoutinesHeader { project_idx: pi });
283                if project.routines_expanded {
284                    for (ri, _) in project.routines.iter().enumerate() {
285                        result.push(FlatEntry::Routine {
286                            project_idx: pi,
287                            routine_idx: ri,
288                        });
289                    }
290                }
291            }
292        }
293    }
294    result
295}
296
297/// Like `flatten_tree` but skips projects whose index is not in `visible`.
298pub fn flatten_tree_filtered(
299    workspace: &WorkspaceState,
300    visible: &HashSet<usize>,
301) -> Vec<FlatEntry> {
302    let mut result = Vec::new();
303    for (pi, project) in workspace.projects.iter().enumerate() {
304        if !visible.contains(&pi) {
305            continue;
306        }
307        result.push(FlatEntry::Project { idx: pi });
308        if project.expanded {
309            for (wi, wt) in project.worktrees.iter().enumerate() {
310                result.push(FlatEntry::Worktree {
311                    project_idx: pi,
312                    worktree_idx: wi,
313                });
314                if wt.expanded {
315                    for (si, session) in wt.sessions.iter().enumerate() {
316                        result.push(FlatEntry::Session {
317                            project_idx: pi,
318                            worktree_idx: wi,
319                            session_idx: si,
320                        });
321                        if session.panes.len() > 1 {
322                            for (pane_idx, _) in session.panes.iter().enumerate() {
323                                result.push(FlatEntry::Pane {
324                                    project_idx: pi,
325                                    worktree_idx: wi,
326                                    session_idx: si,
327                                    pane_idx,
328                                });
329                            }
330                        }
331                    }
332                }
333            }
334            if !project.routines.is_empty() {
335                result.push(FlatEntry::RoutinesHeader { project_idx: pi });
336                if project.routines_expanded {
337                    for (ri, _) in project.routines.iter().enumerate() {
338                        result.push(FlatEntry::Routine {
339                            project_idx: pi,
340                            routine_idx: ri,
341                        });
342                    }
343                }
344            }
345        }
346    }
347    result
348}
349
350/// What is currently focused.
351#[derive(Debug, Clone, PartialEq)]
352pub enum Selection {
353    Project(usize),
354    Worktree(usize, usize),
355    Session(usize, usize, usize),
356    Pane(usize, usize, usize, usize),
357    RoutinesHeader(usize),
358    Routine(usize, usize),
359    None,
360}
361
362impl WorkspaceState {
363    pub fn empty() -> Self {
364        Self {
365            projects: Vec::new(),
366        }
367    }
368
369    pub fn worktree(&self, pi: usize, wi: usize) -> Option<&WorktreeInfo> {
370        self.projects.get(pi)?.worktrees.get(wi)
371    }
372
373    pub fn worktree_mut(&mut self, pi: usize, wi: usize) -> Option<&mut WorktreeInfo> {
374        self.projects.get_mut(pi)?.worktrees.get_mut(wi)
375    }
376
377    pub fn session(&self, pi: usize, wi: usize, si: usize) -> Option<&SessionInfo> {
378        self.projects.get(pi)?.worktrees.get(wi)?.sessions.get(si)
379    }
380
381    pub fn session_mut(&mut self, pi: usize, wi: usize, si: usize) -> Option<&mut SessionInfo> {
382        self.projects
383            .get_mut(pi)?
384            .worktrees
385            .get_mut(wi)?
386            .sessions
387            .get_mut(si)
388    }
389
390    /// Resolve flat index to Selection using a pre-computed flat slice.
391    pub fn get_selection(&self, flat_idx: usize, flat: &[FlatEntry]) -> Selection {
392        match flat.get(flat_idx) {
393            Some(FlatEntry::Project { idx }) => Selection::Project(*idx),
394            Some(FlatEntry::Worktree {
395                project_idx,
396                worktree_idx,
397            }) => Selection::Worktree(*project_idx, *worktree_idx),
398            Some(FlatEntry::Session {
399                project_idx,
400                worktree_idx,
401                session_idx,
402            }) => Selection::Session(*project_idx, *worktree_idx, *session_idx),
403            Some(FlatEntry::Pane {
404                project_idx,
405                worktree_idx,
406                session_idx,
407                pane_idx,
408            }) => Selection::Pane(*project_idx, *worktree_idx, *session_idx, *pane_idx),
409            Some(FlatEntry::RoutinesHeader { project_idx }) => {
410                Selection::RoutinesHeader(*project_idx)
411            }
412            Some(FlatEntry::Routine {
413                project_idx,
414                routine_idx,
415            }) => Selection::Routine(*project_idx, *routine_idx),
416            None => Selection::None,
417        }
418    }
419}