Skip to main content

taskers_control/
controller.rs

1use std::sync::{Arc, Mutex};
2
3use taskers_domain::{AppModel, DomainError, WindowId, WorkspaceId};
4
5use crate::protocol::{ControlCommand, ControlQuery, ControlResponse};
6
7#[derive(Debug, Clone)]
8pub struct InMemoryController {
9    state: Arc<Mutex<ControllerState>>,
10}
11
12#[derive(Debug, Clone)]
13struct ControllerState {
14    model: AppModel,
15    revision: u64,
16}
17
18#[derive(Debug, Clone)]
19pub struct ControllerSnapshot {
20    pub model: AppModel,
21    pub revision: u64,
22}
23
24impl InMemoryController {
25    pub fn new(state: AppModel) -> Self {
26        Self {
27            state: Arc::new(Mutex::new(ControllerState {
28                model: state,
29                revision: 0,
30            })),
31        }
32    }
33
34    pub fn snapshot(&self) -> ControllerSnapshot {
35        let state = self.state.lock().expect("state mutex poisoned").clone();
36        ControllerSnapshot {
37            model: state.model,
38            revision: state.revision,
39        }
40    }
41
42    pub fn revision(&self) -> u64 {
43        self.state.lock().expect("state mutex poisoned").revision
44    }
45
46    pub fn handle(&self, command: ControlCommand) -> Result<ControlResponse, DomainError> {
47        let mut state = self.state.lock().expect("state mutex poisoned");
48        let model = &mut state.model;
49
50        let (response, mutated) = match command {
51            ControlCommand::CreateWorkspace { label } => {
52                let workspace_id = model.create_workspace(label);
53                (ControlResponse::WorkspaceCreated { workspace_id }, true)
54            }
55            ControlCommand::RenameWorkspace {
56                workspace_id,
57                label,
58            } => {
59                model.rename_workspace(workspace_id, label)?;
60                (
61                    ControlResponse::Ack {
62                        message: "workspace renamed".into(),
63                    },
64                    true,
65                )
66            }
67            ControlCommand::SwitchWorkspace {
68                window_id,
69                workspace_id,
70            } => {
71                let target_window = window_id.unwrap_or(model.active_window);
72                model.switch_workspace(target_window, workspace_id)?;
73                (
74                    ControlResponse::Ack {
75                        message: "workspace switched".into(),
76                    },
77                    true,
78                )
79            }
80            ControlCommand::SplitPane {
81                workspace_id,
82                pane_id,
83                axis,
84            } => {
85                let new_pane_id = model.split_pane(workspace_id, pane_id, axis)?;
86                (
87                    ControlResponse::PaneSplit {
88                        pane_id: new_pane_id,
89                    },
90                    true,
91                )
92            }
93            ControlCommand::CreateWorkspaceWindow {
94                workspace_id,
95                direction,
96            } => {
97                let new_pane_id = model.create_workspace_window(workspace_id, direction)?;
98                (
99                    ControlResponse::WorkspaceWindowCreated {
100                        pane_id: new_pane_id,
101                    },
102                    true,
103                )
104            }
105            ControlCommand::FocusWorkspaceWindow {
106                workspace_id,
107                workspace_window_id,
108            } => {
109                model.focus_workspace_window(workspace_id, workspace_window_id)?;
110                (
111                    ControlResponse::Ack {
112                        message: "workspace window focused".into(),
113                    },
114                    true,
115                )
116            }
117            ControlCommand::FocusPane {
118                workspace_id,
119                pane_id,
120            } => {
121                model.focus_pane(workspace_id, pane_id)?;
122                (
123                    ControlResponse::Ack {
124                        message: "pane focused".into(),
125                    },
126                    true,
127                )
128            }
129            ControlCommand::FocusPaneDirection {
130                workspace_id,
131                direction,
132            } => {
133                model.focus_pane_direction(workspace_id, direction)?;
134                (
135                    ControlResponse::Ack {
136                        message: "pane focus moved".into(),
137                    },
138                    true,
139                )
140            }
141            ControlCommand::ResizeActiveWindow {
142                workspace_id,
143                direction,
144                amount,
145            } => {
146                model.resize_active_window(workspace_id, direction, amount)?;
147                (
148                    ControlResponse::Ack {
149                        message: "workspace window resized".into(),
150                    },
151                    true,
152                )
153            }
154            ControlCommand::ResizeActivePaneSplit {
155                workspace_id,
156                direction,
157                amount,
158            } => {
159                model.resize_active_pane_split(workspace_id, direction, amount)?;
160                (
161                    ControlResponse::Ack {
162                        message: "pane split resized".into(),
163                    },
164                    true,
165                )
166            }
167            ControlCommand::SetWorkspaceColumnWidth {
168                workspace_id,
169                workspace_column_id,
170                width,
171            } => {
172                model.set_workspace_column_width(workspace_id, workspace_column_id, width)?;
173                (
174                    ControlResponse::Ack {
175                        message: "workspace column width updated".into(),
176                    },
177                    true,
178                )
179            }
180            ControlCommand::SetWorkspaceWindowHeight {
181                workspace_id,
182                workspace_window_id,
183                height,
184            } => {
185                model.set_workspace_window_height(workspace_id, workspace_window_id, height)?;
186                (
187                    ControlResponse::Ack {
188                        message: "workspace window height updated".into(),
189                    },
190                    true,
191                )
192            }
193            ControlCommand::SetWindowSplitRatio {
194                workspace_id,
195                workspace_window_id,
196                path,
197                ratio,
198            } => {
199                model.set_window_split_ratio(workspace_id, workspace_window_id, &path, ratio)?;
200                (
201                    ControlResponse::Ack {
202                        message: "window split ratio updated".into(),
203                    },
204                    true,
205                )
206            }
207            ControlCommand::UpdatePaneMetadata { pane_id, patch } => {
208                model.update_pane_metadata(pane_id, patch)?;
209                (
210                    ControlResponse::Ack {
211                        message: "pane metadata updated".into(),
212                    },
213                    true,
214                )
215            }
216            ControlCommand::UpdateSurfaceMetadata { surface_id, patch } => {
217                model.update_surface_metadata(surface_id, patch)?;
218                (
219                    ControlResponse::Ack {
220                        message: "surface metadata updated".into(),
221                    },
222                    true,
223                )
224            }
225            ControlCommand::CreateSurface {
226                workspace_id,
227                pane_id,
228                kind,
229            } => {
230                let surface_id = model.create_surface(workspace_id, pane_id, kind)?;
231                (ControlResponse::SurfaceCreated { surface_id }, true)
232            }
233            ControlCommand::FocusSurface {
234                workspace_id,
235                pane_id,
236                surface_id,
237            } => {
238                model.focus_surface(workspace_id, pane_id, surface_id)?;
239                (
240                    ControlResponse::Ack {
241                        message: "surface focused".into(),
242                    },
243                    true,
244                )
245            }
246            ControlCommand::MarkSurfaceCompleted {
247                workspace_id,
248                pane_id,
249                surface_id,
250            } => {
251                model.mark_surface_completed(workspace_id, pane_id, surface_id)?;
252                (
253                    ControlResponse::Ack {
254                        message: "surface marked completed".into(),
255                    },
256                    true,
257                )
258            }
259            ControlCommand::CloseSurface {
260                workspace_id,
261                pane_id,
262                surface_id,
263            } => {
264                model.close_surface(workspace_id, pane_id, surface_id)?;
265                (
266                    ControlResponse::Ack {
267                        message: "surface closed".into(),
268                    },
269                    true,
270                )
271            }
272            ControlCommand::MoveSurface {
273                workspace_id,
274                pane_id,
275                surface_id,
276                to_index,
277            } => {
278                model.move_surface(workspace_id, pane_id, surface_id, to_index)?;
279                (
280                    ControlResponse::Ack {
281                        message: "surface moved".into(),
282                    },
283                    true,
284                )
285            }
286            ControlCommand::SetWorkspaceViewport {
287                workspace_id,
288                viewport,
289            } => {
290                model.set_workspace_viewport(workspace_id, viewport)?;
291                (
292                    ControlResponse::Ack {
293                        message: "workspace viewport updated".into(),
294                    },
295                    true,
296                )
297            }
298            ControlCommand::ClosePane {
299                workspace_id,
300                pane_id,
301            } => {
302                model.close_pane(workspace_id, pane_id)?;
303                (
304                    ControlResponse::Ack {
305                        message: "pane closed".into(),
306                    },
307                    true,
308                )
309            }
310            ControlCommand::CloseWorkspace { workspace_id } => {
311                model.close_workspace(workspace_id)?;
312                (
313                    ControlResponse::Ack {
314                        message: "workspace closed".into(),
315                    },
316                    true,
317                )
318            }
319            ControlCommand::EmitSignal {
320                workspace_id,
321                pane_id,
322                surface_id,
323                event,
324            } => {
325                if let Some(surface_id) = surface_id {
326                    model.apply_surface_signal(workspace_id, pane_id, surface_id, event)?;
327                } else {
328                    model.apply_signal(workspace_id, pane_id, event)?;
329                }
330                (
331                    ControlResponse::Ack {
332                        message: "signal applied".into(),
333                    },
334                    true,
335                )
336            }
337            ControlCommand::QueryStatus { query } => match query {
338                ControlQuery::ActiveWindow | ControlQuery::All => (
339                    ControlResponse::Status {
340                        session: model.snapshot(),
341                    },
342                    false,
343                ),
344                ControlQuery::Window { window_id } => (window_snapshot(model, window_id)?, false),
345                ControlQuery::Workspace { workspace_id } => {
346                    (workspace_snapshot(model, workspace_id)?, false)
347                }
348            },
349        };
350
351        if mutated {
352            state.revision = state.revision.saturating_add(1);
353        }
354
355        Ok(response)
356    }
357}
358
359fn window_snapshot(model: &AppModel, window_id: WindowId) -> Result<ControlResponse, DomainError> {
360    let _ = model
361        .windows
362        .get(&window_id)
363        .ok_or(DomainError::MissingWindow(window_id))?;
364    Ok(ControlResponse::Status {
365        session: model.snapshot(),
366    })
367}
368
369fn workspace_snapshot(
370    model: &AppModel,
371    workspace_id: WorkspaceId,
372) -> Result<ControlResponse, DomainError> {
373    let _ = model
374        .workspaces
375        .get(&workspace_id)
376        .ok_or(DomainError::MissingWorkspace(workspace_id))?;
377    Ok(ControlResponse::WorkspaceState {
378        workspace_id,
379        session: model.snapshot(),
380    })
381}
382
383#[cfg(test)]
384mod tests {
385    use taskers_domain::{AppModel, SignalEvent, SignalKind};
386
387    use crate::{ControlCommand, ControlQuery};
388
389    use super::InMemoryController;
390
391    #[test]
392    fn revision_increments_for_mutations_but_not_queries() {
393        let controller = InMemoryController::new(AppModel::new("Main"));
394        assert_eq!(controller.revision(), 0);
395
396        controller
397            .handle(ControlCommand::QueryStatus {
398                query: ControlQuery::All,
399            })
400            .expect("query status");
401        assert_eq!(controller.revision(), 0);
402
403        controller
404            .handle(ControlCommand::CreateWorkspace {
405                label: "Docs".into(),
406            })
407            .expect("create workspace");
408        assert_eq!(controller.revision(), 1);
409        assert_eq!(controller.snapshot().revision, 1);
410    }
411
412    #[test]
413    fn revision_increments_for_signal_mutations() {
414        let controller = InMemoryController::new(AppModel::new("Main"));
415        let snapshot = controller.snapshot();
416        let workspace = snapshot.model.active_workspace().expect("workspace");
417
418        controller
419            .handle(ControlCommand::EmitSignal {
420                workspace_id: workspace.id,
421                pane_id: workspace.active_pane,
422                surface_id: None,
423                event: SignalEvent::new("pty", SignalKind::Progress, Some("Running".into())),
424            })
425            .expect("emit signal");
426
427        assert_eq!(controller.revision(), 1);
428    }
429}