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    /// Inserts a process at the end of its sidebar section and returns its index.
32    pub fn insert_in_section(&mut self, process: Process) -> usize {
33        let section = process.kind().section_index();
34        let index = self
35            .processes
36            .iter()
37            .position(|existing| existing.kind().section_index() > section)
38            .unwrap_or(self.processes.len());
39        self.processes.insert(index, process);
40        index
41    }
42
43    /// Removes the process owning `pane`, preserving the selected process when
44    /// another row is removed and otherwise selecting the adjacent row.
45    pub fn remove(&mut self, pane: PaneId) {
46        if let Some(index) = self.position_of(pane) {
47            let selected = self.selected_process().map(|process| *process.id());
48            self.processes.remove(index);
49            self.selected_index = selected
50                .filter(|selected| *selected != pane)
51                .and_then(|selected| self.position_of(selected))
52                .unwrap_or_else(|| index.min(self.processes.len().saturating_sub(1)));
53        }
54    }
55
56    /// Moves the selection to the next process, wrapping around.
57    pub fn select_next(&mut self) {
58        if !self.processes.is_empty() {
59            self.selected_index = (self.selected_index + 1) % self.processes.len();
60        }
61    }
62
63    /// Selects the process at `index`, clamped to the valid range. A no-op when
64    /// the workspace is empty.
65    pub fn select_at(&mut self, index: usize) {
66        if !self.processes.is_empty() {
67            self.selected_index = index.min(self.processes.len() - 1);
68        }
69    }
70
71    /// Moves the selection to the previous process, wrapping around.
72    pub fn select_previous(&mut self) {
73        if !self.processes.is_empty() {
74            let last = self.processes.len() - 1;
75            self.selected_index = if self.selected_index == 0 {
76                last
77            } else {
78                self.selected_index - 1
79            };
80        }
81    }
82
83    /// Index of the process owning `pane`, if present.
84    pub fn position_of(&self, pane: PaneId) -> Option<usize> {
85        self.processes
86            .iter()
87            .position(|process| *process.id() == pane)
88    }
89
90    /// The process owning `pane`, if present.
91    pub fn process(&self, pane: PaneId) -> Option<&Process> {
92        self.position_of(pane).map(|index| &self.processes[index])
93    }
94
95    /// Updates the lifecycle state of the process owning `pane`.
96    pub fn set_state(&mut self, pane: PaneId, state: ProcessState) {
97        if let Some(index) = self.position_of(pane) {
98            self.processes[index].set_state(state);
99        }
100    }
101
102    /// Sets the autostart flag of the process owning `pane`.
103    pub fn set_autostart(&mut self, pane: PaneId, autostart: bool) {
104        if let Some(index) = self.position_of(pane) {
105            self.processes[index].set_autostart(autostart);
106        }
107    }
108
109    /// Updates the inferred activity of the process owning `pane`.
110    pub fn set_activity(&mut self, pane: PaneId, activity: ActivityState) {
111        if let Some(index) = self.position_of(pane) {
112            self.processes[index].set_activity(activity);
113        }
114    }
115
116    /// Whether the process owning `pane` should be restarted after `outcome`.
117    pub fn should_restart(&self, pane: PaneId, outcome: ExitOutcome) -> bool {
118        self.process(pane)
119            .map(|process| process.restart().should_restart(outcome))
120            .unwrap_or(false)
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::domain::{
128        process::{ProcessKind, RestartPolicy},
129        value::{CommandLine, ProcessName},
130    };
131
132    fn process(id: u64, restart: RestartPolicy) -> Process {
133        Process::builder()
134            .id(PaneId::new(id))
135            .name(ProcessName::try_new(format!("p{id}")).unwrap())
136            .kind(ProcessKind::Command)
137            .command(Some(CommandLine::try_new("true").unwrap()))
138            .restart(restart)
139            .build()
140    }
141
142    fn workspace() -> Workspace {
143        Workspace::builder()
144            .processes(vec![
145                process(0, RestartPolicy::Never),
146                process(1, RestartPolicy::Always),
147            ])
148            .build()
149    }
150
151    #[test]
152    fn selection_wraps_in_both_directions() {
153        let mut ws = workspace();
154        assert_eq!(*ws.selected_index(), 0);
155        ws.select_previous();
156        assert_eq!(*ws.selected_index(), 1);
157        ws.select_next();
158        assert_eq!(*ws.selected_index(), 0);
159    }
160
161    #[test]
162    fn set_state_updates_the_named_process() {
163        let mut ws = workspace();
164        ws.set_state(PaneId::new(1), ProcessState::Running);
165        assert_eq!(
166            *ws.process(PaneId::new(1)).unwrap().state(),
167            ProcessState::Running
168        );
169    }
170
171    /// Removing an earlier row keeps selection on the same process identity.
172    #[test]
173    fn removing_an_earlier_process_preserves_selection() {
174        let mut ws = Workspace::builder()
175            .processes(vec![
176                process(0, RestartPolicy::Never),
177                process(1, RestartPolicy::Never),
178                process(2, RestartPolicy::Never),
179            ])
180            .selected_index(2)
181            .build();
182
183        ws.remove(PaneId::new(0));
184
185        assert_eq!(*ws.selected_process().unwrap().id(), PaneId::new(2));
186        assert_eq!(*ws.selected_index(), 1);
187    }
188
189    /// Removing the selected row chooses the row that occupied its position,
190    /// falling back to the preceding row when the last process was removed.
191    #[test]
192    fn removing_the_selected_process_selects_an_adjacent_process() {
193        let mut ws = Workspace::builder()
194            .processes(vec![
195                process(0, RestartPolicy::Never),
196                process(1, RestartPolicy::Never),
197                process(2, RestartPolicy::Never),
198            ])
199            .selected_index(1)
200            .build();
201
202        ws.remove(PaneId::new(1));
203        assert_eq!(*ws.selected_process().unwrap().id(), PaneId::new(2));
204
205        ws.remove(PaneId::new(2));
206        assert_eq!(*ws.selected_process().unwrap().id(), PaneId::new(0));
207    }
208
209    #[test]
210    fn restart_decision_follows_policy() {
211        let ws = workspace();
212        assert!(!ws.should_restart(PaneId::new(0), ExitOutcome::Failed));
213        assert!(ws.should_restart(PaneId::new(1), ExitOutcome::Failed));
214    }
215
216    /// A runtime process joins its kind's existing sidebar section.
217    #[test]
218    fn inserts_a_process_at_the_end_of_its_section() {
219        let mut ws = workspace();
220        let agent = Process::builder()
221            .id(PaneId::new(2))
222            .name(ProcessName::try_new("agent").unwrap())
223            .kind(ProcessKind::Agent)
224            .build();
225
226        let index = ws.insert_in_section(agent);
227
228        assert_eq!(index, 0);
229        assert_eq!(*ws.processes()[index].kind(), ProcessKind::Agent);
230    }
231}