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}