Skip to main content

muster/application/
workspace.rs

1use getset::Getters;
2use typed_builder::TypedBuilder;
3
4use crate::domain::{
5    process::{ActivityState, Process, ProcessState},
6    pty::ExitOutcome,
7    value::PaneId,
8};
9
10/// In-memory workspace state: the ordered processes and the current selection.
11/// Pure domain orchestration, free of any rendering or I/O concern.
12#[derive(Getters, TypedBuilder)]
13#[getset(get = "pub")]
14pub struct Workspace {
15    processes: Vec<Process>,
16    #[builder(default)]
17    selected_index: usize,
18}
19
20impl Workspace {
21    /// The currently selected process, if any.
22    pub fn selected_process(&self) -> Option<&Process> {
23        self.processes.get(self.selected_index)
24    }
25
26    /// Whether the workspace has no processes.
27    pub fn is_empty(&self) -> bool {
28        self.processes.is_empty()
29    }
30
31    /// Appends a process to the end of the workspace, leaving the selection and
32    /// every existing process untouched.
33    pub fn add_process(&mut self, process: Process) {
34        self.processes.push(process);
35    }
36
37    /// Removes the process owning `pane`, clamping the selection to stay valid.
38    pub fn remove(&mut self, pane: PaneId) {
39        if let Some(index) = self.position_of(pane) {
40            self.processes.remove(index);
41            if self.selected_index >= self.processes.len() {
42                self.selected_index = self.processes.len().saturating_sub(1);
43            }
44        }
45    }
46
47    /// Moves the selection to the next process, wrapping around.
48    pub fn select_next(&mut self) {
49        if !self.processes.is_empty() {
50            self.selected_index = (self.selected_index + 1) % self.processes.len();
51        }
52    }
53
54    /// Selects the process at `index`, clamped to the valid range. A no-op when
55    /// the workspace is empty.
56    pub fn select_at(&mut self, index: usize) {
57        if !self.processes.is_empty() {
58            self.selected_index = index.min(self.processes.len() - 1);
59        }
60    }
61
62    /// Moves the selection to the previous process, wrapping around.
63    pub fn select_previous(&mut self) {
64        if !self.processes.is_empty() {
65            let last = self.processes.len() - 1;
66            self.selected_index = if self.selected_index == 0 {
67                last
68            } else {
69                self.selected_index - 1
70            };
71        }
72    }
73
74    /// Index of the process owning `pane`, if present.
75    pub fn position_of(&self, pane: PaneId) -> Option<usize> {
76        self.processes
77            .iter()
78            .position(|process| *process.id() == pane)
79    }
80
81    /// The process owning `pane`, if present.
82    pub fn process(&self, pane: PaneId) -> Option<&Process> {
83        self.position_of(pane).map(|index| &self.processes[index])
84    }
85
86    /// Updates the lifecycle state of the process owning `pane`.
87    pub fn set_state(&mut self, pane: PaneId, state: ProcessState) {
88        if let Some(index) = self.position_of(pane) {
89            self.processes[index].set_state(state);
90        }
91    }
92
93    /// Sets the autostart flag of the process owning `pane`.
94    pub fn set_autostart(&mut self, pane: PaneId, autostart: bool) {
95        if let Some(index) = self.position_of(pane) {
96            self.processes[index].set_autostart(autostart);
97        }
98    }
99
100    /// Updates the inferred activity of the process owning `pane`.
101    pub fn set_activity(&mut self, pane: PaneId, activity: ActivityState) {
102        if let Some(index) = self.position_of(pane) {
103            self.processes[index].set_activity(activity);
104        }
105    }
106
107    /// Whether the process owning `pane` should be restarted after `outcome`.
108    pub fn should_restart(&self, pane: PaneId, outcome: ExitOutcome) -> bool {
109        self.process(pane)
110            .map(|process| process.restart().should_restart(outcome))
111            .unwrap_or(false)
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::domain::{
119        process::{ProcessKind, RestartPolicy},
120        value::{CommandLine, ProcessName},
121    };
122
123    fn process(id: u64, restart: RestartPolicy) -> Process {
124        Process::builder()
125            .id(PaneId::new(id))
126            .name(ProcessName::try_new(format!("p{id}")).unwrap())
127            .kind(ProcessKind::Command)
128            .command(Some(CommandLine::try_new("true").unwrap()))
129            .restart(restart)
130            .build()
131    }
132
133    fn workspace() -> Workspace {
134        Workspace::builder()
135            .processes(vec![
136                process(0, RestartPolicy::Never),
137                process(1, RestartPolicy::Always),
138            ])
139            .build()
140    }
141
142    #[test]
143    fn selection_wraps_in_both_directions() {
144        let mut ws = workspace();
145        assert_eq!(*ws.selected_index(), 0);
146        ws.select_previous();
147        assert_eq!(*ws.selected_index(), 1);
148        ws.select_next();
149        assert_eq!(*ws.selected_index(), 0);
150    }
151
152    #[test]
153    fn set_state_updates_the_named_process() {
154        let mut ws = workspace();
155        ws.set_state(PaneId::new(1), ProcessState::Running);
156        assert_eq!(
157            *ws.process(PaneId::new(1)).unwrap().state(),
158            ProcessState::Running
159        );
160    }
161
162    #[test]
163    fn restart_decision_follows_policy() {
164        let ws = workspace();
165        assert!(!ws.should_restart(PaneId::new(0), ExitOutcome::Failed));
166        assert!(ws.should_restart(PaneId::new(1), ExitOutcome::Failed));
167    }
168}