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