1use std::sync::{Arc, Mutex};
2
3use taskers_domain::{
4 AppModel, DomainError, PaneId, PaneKind, SurfaceId, SurfaceRecord, WindowId, WorkspaceId,
5};
6
7use crate::protocol::{
8 ControlCommand, ControlQuery, ControlResponse, IdentifyContext, IdentifyResult,
9};
10
11#[derive(Debug, Clone)]
12pub struct InMemoryController {
13 state: Arc<Mutex<ControllerState>>,
14}
15
16#[derive(Debug, Clone)]
17struct ControllerState {
18 model: AppModel,
19 revision: u64,
20}
21
22#[derive(Debug, Clone)]
23pub struct ControllerSnapshot {
24 pub model: AppModel,
25 pub revision: u64,
26}
27
28impl InMemoryController {
29 pub fn new(state: AppModel) -> Self {
30 Self {
31 state: Arc::new(Mutex::new(ControllerState {
32 model: state,
33 revision: 0,
34 })),
35 }
36 }
37
38 pub fn snapshot(&self) -> ControllerSnapshot {
39 let state = self.state.lock().expect("state mutex poisoned").clone();
40 ControllerSnapshot {
41 model: state.model,
42 revision: state.revision,
43 }
44 }
45
46 pub fn revision(&self) -> u64 {
47 self.state.lock().expect("state mutex poisoned").revision
48 }
49
50 pub fn handle(&self, command: ControlCommand) -> Result<ControlResponse, DomainError> {
51 let mut state = self.state.lock().expect("state mutex poisoned");
52 let model = &mut state.model;
53
54 let (response, mutated) = match command {
55 ControlCommand::CreateWorkspace { label } => {
56 let workspace_id = model.create_workspace(label);
57 (ControlResponse::WorkspaceCreated { workspace_id }, true)
58 }
59 ControlCommand::RenameWorkspace {
60 workspace_id,
61 label,
62 } => {
63 model.rename_workspace(workspace_id, label)?;
64 (
65 ControlResponse::Ack {
66 message: "workspace renamed".into(),
67 },
68 true,
69 )
70 }
71 ControlCommand::SwitchWorkspace {
72 window_id,
73 workspace_id,
74 } => {
75 let target_window = window_id.unwrap_or(model.active_window);
76 model.switch_workspace(target_window, workspace_id)?;
77 (
78 ControlResponse::Ack {
79 message: "workspace switched".into(),
80 },
81 true,
82 )
83 }
84 ControlCommand::SplitPane {
85 workspace_id,
86 pane_id,
87 axis,
88 } => {
89 let new_pane_id = model.split_pane(workspace_id, pane_id, axis)?;
90 (
91 ControlResponse::PaneSplit {
92 pane_id: new_pane_id,
93 },
94 true,
95 )
96 }
97 ControlCommand::SplitPaneDirection {
98 workspace_id,
99 pane_id,
100 direction,
101 } => {
102 let new_pane_id =
103 model.split_pane_direction(workspace_id, Some(pane_id), direction)?;
104 (
105 ControlResponse::PaneSplit {
106 pane_id: new_pane_id,
107 },
108 true,
109 )
110 }
111 ControlCommand::CreateWorkspaceWindow {
112 workspace_id,
113 direction,
114 } => {
115 let new_pane_id = model.create_workspace_window(workspace_id, direction)?;
116 (
117 ControlResponse::WorkspaceWindowCreated {
118 pane_id: new_pane_id,
119 },
120 true,
121 )
122 }
123 ControlCommand::FocusWorkspaceWindow {
124 workspace_id,
125 workspace_window_id,
126 } => {
127 model.focus_workspace_window(workspace_id, workspace_window_id)?;
128 (
129 ControlResponse::Ack {
130 message: "workspace window focused".into(),
131 },
132 true,
133 )
134 }
135 ControlCommand::MoveWorkspaceWindow {
136 workspace_id,
137 workspace_window_id,
138 target,
139 } => {
140 model.move_workspace_window(workspace_id, workspace_window_id, target)?;
141 (
142 ControlResponse::Ack {
143 message: "workspace window moved".into(),
144 },
145 true,
146 )
147 }
148 ControlCommand::CreateWorkspaceWindowTab {
149 workspace_id,
150 workspace_window_id,
151 } => {
152 let (workspace_window_tab_id, pane_id) =
153 model.create_workspace_window_tab(workspace_id, workspace_window_id)?;
154 (
155 ControlResponse::WorkspaceWindowTabCreated {
156 pane_id,
157 workspace_window_tab_id,
158 },
159 true,
160 )
161 }
162 ControlCommand::FocusWorkspaceWindowTab {
163 workspace_id,
164 workspace_window_id,
165 workspace_window_tab_id,
166 } => {
167 model.focus_workspace_window_tab(
168 workspace_id,
169 workspace_window_id,
170 workspace_window_tab_id,
171 )?;
172 (
173 ControlResponse::Ack {
174 message: "workspace window tab focused".into(),
175 },
176 true,
177 )
178 }
179 ControlCommand::MoveWorkspaceWindowTab {
180 workspace_id,
181 workspace_window_id,
182 workspace_window_tab_id,
183 to_index,
184 } => {
185 model.move_workspace_window_tab(
186 workspace_id,
187 workspace_window_id,
188 workspace_window_tab_id,
189 to_index,
190 )?;
191 (
192 ControlResponse::Ack {
193 message: "workspace window tab moved".into(),
194 },
195 true,
196 )
197 }
198 ControlCommand::TransferWorkspaceWindowTab {
199 workspace_id,
200 source_workspace_window_id,
201 workspace_window_tab_id,
202 target_workspace_window_id,
203 to_index,
204 } => {
205 model.transfer_workspace_window_tab(
206 workspace_id,
207 source_workspace_window_id,
208 workspace_window_tab_id,
209 target_workspace_window_id,
210 to_index,
211 )?;
212 (
213 ControlResponse::Ack {
214 message: "workspace window tab transferred".into(),
215 },
216 true,
217 )
218 }
219 ControlCommand::ExtractWorkspaceWindowTab {
220 workspace_id,
221 source_workspace_window_id,
222 workspace_window_tab_id,
223 target,
224 } => {
225 model.extract_workspace_window_tab(
226 workspace_id,
227 source_workspace_window_id,
228 workspace_window_tab_id,
229 target,
230 )?;
231 (
232 ControlResponse::Ack {
233 message: "workspace window tab extracted".into(),
234 },
235 true,
236 )
237 }
238 ControlCommand::CloseWorkspaceWindowTab {
239 workspace_id,
240 workspace_window_id,
241 workspace_window_tab_id,
242 } => {
243 model.close_workspace_window_tab(
244 workspace_id,
245 workspace_window_id,
246 workspace_window_tab_id,
247 )?;
248 (
249 ControlResponse::Ack {
250 message: "workspace window tab closed".into(),
251 },
252 true,
253 )
254 }
255 ControlCommand::FocusPane {
256 workspace_id,
257 pane_id,
258 } => {
259 model.focus_pane(workspace_id, pane_id)?;
260 (
261 ControlResponse::Ack {
262 message: "pane focused".into(),
263 },
264 true,
265 )
266 }
267 ControlCommand::FocusPaneDirection {
268 workspace_id,
269 direction,
270 } => {
271 model.focus_pane_direction(workspace_id, direction)?;
272 (
273 ControlResponse::Ack {
274 message: "pane focus moved".into(),
275 },
276 true,
277 )
278 }
279 ControlCommand::ResizeActiveWindow {
280 workspace_id,
281 direction,
282 amount,
283 } => {
284 model.resize_active_window(workspace_id, direction, amount)?;
285 (
286 ControlResponse::Ack {
287 message: "workspace window resized".into(),
288 },
289 true,
290 )
291 }
292 ControlCommand::ResizeActivePaneSplit {
293 workspace_id,
294 direction,
295 amount,
296 } => {
297 model.resize_active_pane_split(workspace_id, direction, amount)?;
298 (
299 ControlResponse::Ack {
300 message: "pane split resized".into(),
301 },
302 true,
303 )
304 }
305 ControlCommand::SetWorkspaceColumnWidth {
306 workspace_id,
307 workspace_column_id,
308 width,
309 } => {
310 model.set_workspace_column_width(workspace_id, workspace_column_id, width)?;
311 (
312 ControlResponse::Ack {
313 message: "workspace column width updated".into(),
314 },
315 true,
316 )
317 }
318 ControlCommand::SetWorkspaceWindowHeight {
319 workspace_id,
320 workspace_window_id,
321 height,
322 } => {
323 model.set_workspace_window_height(workspace_id, workspace_window_id, height)?;
324 (
325 ControlResponse::Ack {
326 message: "workspace window height updated".into(),
327 },
328 true,
329 )
330 }
331 ControlCommand::SetWindowSplitRatio {
332 workspace_id,
333 workspace_window_id,
334 path,
335 ratio,
336 } => {
337 model.set_window_split_ratio(workspace_id, workspace_window_id, &path, ratio)?;
338 (
339 ControlResponse::Ack {
340 message: "window split ratio updated".into(),
341 },
342 true,
343 )
344 }
345 ControlCommand::UpdatePaneMetadata { pane_id, patch } => {
346 model.update_pane_metadata(pane_id, patch)?;
347 (
348 ControlResponse::Ack {
349 message: "pane metadata updated".into(),
350 },
351 true,
352 )
353 }
354 ControlCommand::UpdateSurfaceMetadata { surface_id, patch } => {
355 model.update_surface_metadata(surface_id, patch)?;
356 (
357 ControlResponse::Ack {
358 message: "surface metadata updated".into(),
359 },
360 true,
361 )
362 }
363 ControlCommand::CreateSurface {
364 workspace_id,
365 pane_id,
366 kind,
367 } => {
368 let surface_id = model.create_surface(workspace_id, pane_id, kind)?;
369 (ControlResponse::SurfaceCreated { surface_id }, true)
370 }
371 ControlCommand::FocusSurface {
372 workspace_id,
373 pane_id,
374 surface_id,
375 } => {
376 model.focus_surface(workspace_id, pane_id, surface_id)?;
377 (
378 ControlResponse::Ack {
379 message: "surface focused".into(),
380 },
381 true,
382 )
383 }
384 ControlCommand::StartSurfaceAgentSession {
385 workspace_id,
386 pane_id,
387 surface_id,
388 agent_kind,
389 } => {
390 model.start_surface_agent_session(workspace_id, pane_id, surface_id, agent_kind)?;
391 (
392 ControlResponse::Ack {
393 message: "surface agent session started".into(),
394 },
395 true,
396 )
397 }
398 ControlCommand::StopSurfaceAgentSession {
399 workspace_id,
400 pane_id,
401 surface_id,
402 exit_status,
403 } => {
404 model.stop_surface_agent_session(workspace_id, pane_id, surface_id, exit_status)?;
405 (
406 ControlResponse::Ack {
407 message: "surface agent session stopped".into(),
408 },
409 true,
410 )
411 }
412 ControlCommand::MarkSurfaceCompleted {
413 workspace_id,
414 pane_id,
415 surface_id,
416 } => {
417 model.mark_surface_completed(workspace_id, pane_id, surface_id)?;
418 (
419 ControlResponse::Ack {
420 message: "surface marked completed".into(),
421 },
422 true,
423 )
424 }
425 ControlCommand::CloseSurface {
426 workspace_id,
427 pane_id,
428 surface_id,
429 } => {
430 model.close_surface(workspace_id, pane_id, surface_id)?;
431 (
432 ControlResponse::Ack {
433 message: "surface closed".into(),
434 },
435 true,
436 )
437 }
438 ControlCommand::MoveSurface {
439 workspace_id,
440 pane_id,
441 surface_id,
442 to_index,
443 } => {
444 model.move_surface(workspace_id, pane_id, surface_id, to_index)?;
445 (
446 ControlResponse::Ack {
447 message: "surface moved".into(),
448 },
449 true,
450 )
451 }
452 ControlCommand::TransferSurface {
453 source_workspace_id,
454 source_pane_id,
455 surface_id,
456 target_workspace_id,
457 target_pane_id,
458 to_index,
459 } => {
460 model.transfer_surface(
461 source_workspace_id,
462 source_pane_id,
463 surface_id,
464 target_workspace_id,
465 target_pane_id,
466 to_index,
467 )?;
468 (
469 ControlResponse::Ack {
470 message: "surface transferred".into(),
471 },
472 true,
473 )
474 }
475 ControlCommand::MoveSurfaceToSplit {
476 source_workspace_id,
477 source_pane_id,
478 surface_id,
479 target_workspace_id,
480 target_pane_id,
481 direction,
482 } => {
483 let new_pane_id = model.move_surface_to_split(
484 source_workspace_id,
485 source_pane_id,
486 surface_id,
487 target_workspace_id,
488 target_pane_id,
489 direction,
490 )?;
491 (
492 ControlResponse::SurfaceMovedToSplit {
493 pane_id: new_pane_id,
494 },
495 true,
496 )
497 }
498 ControlCommand::MoveSurfaceToWorkspace {
499 source_workspace_id,
500 source_pane_id,
501 surface_id,
502 target_workspace_id,
503 } => {
504 let new_pane_id = model.move_surface_to_workspace(
505 source_workspace_id,
506 source_pane_id,
507 surface_id,
508 target_workspace_id,
509 )?;
510 (
511 ControlResponse::SurfaceMovedToWorkspace {
512 pane_id: new_pane_id,
513 },
514 true,
515 )
516 }
517 ControlCommand::SetWorkspaceViewport {
518 workspace_id,
519 viewport,
520 } => {
521 model.set_workspace_viewport(workspace_id, viewport)?;
522 (
523 ControlResponse::Ack {
524 message: "workspace viewport updated".into(),
525 },
526 true,
527 )
528 }
529 ControlCommand::ClosePane {
530 workspace_id,
531 pane_id,
532 } => {
533 model.close_pane(workspace_id, pane_id)?;
534 (
535 ControlResponse::Ack {
536 message: "pane closed".into(),
537 },
538 true,
539 )
540 }
541 ControlCommand::CloseWorkspace { workspace_id } => {
542 model.close_workspace(workspace_id)?;
543 (
544 ControlResponse::Ack {
545 message: "workspace closed".into(),
546 },
547 true,
548 )
549 }
550 ControlCommand::ReorderWorkspaces {
551 window_id,
552 workspace_ids,
553 } => {
554 model.reorder_workspaces(window_id, workspace_ids)?;
555 (
556 ControlResponse::Ack {
557 message: "workspaces reordered".into(),
558 },
559 true,
560 )
561 }
562 ControlCommand::EmitSignal {
563 workspace_id,
564 pane_id,
565 surface_id,
566 event,
567 } => {
568 if let Some(surface_id) = surface_id {
569 let current = resolve_identify_context(model, None, None, Some(surface_id))?;
570 model.apply_surface_signal(
571 current.workspace_id,
572 current.pane_id,
573 surface_id,
574 event,
575 )?;
576 } else {
577 model.apply_signal(workspace_id, pane_id, event)?;
578 }
579 (
580 ControlResponse::Ack {
581 message: "signal applied".into(),
582 },
583 true,
584 )
585 }
586 ControlCommand::AgentSetStatus { workspace_id, text } => {
587 model.set_workspace_status(workspace_id, text)?;
588 (
589 ControlResponse::Ack {
590 message: "workspace agent status updated".into(),
591 },
592 true,
593 )
594 }
595 ControlCommand::AgentClearStatus { workspace_id } => {
596 model.clear_workspace_status(workspace_id)?;
597 (
598 ControlResponse::Ack {
599 message: "workspace agent status cleared".into(),
600 },
601 true,
602 )
603 }
604 ControlCommand::AgentSetProgress {
605 workspace_id,
606 progress,
607 } => {
608 model.set_workspace_progress(workspace_id, progress)?;
609 (
610 ControlResponse::Ack {
611 message: "workspace progress updated".into(),
612 },
613 true,
614 )
615 }
616 ControlCommand::AgentClearProgress { workspace_id } => {
617 model.clear_workspace_progress(workspace_id)?;
618 (
619 ControlResponse::Ack {
620 message: "workspace progress cleared".into(),
621 },
622 true,
623 )
624 }
625 ControlCommand::AgentAppendLog {
626 workspace_id,
627 entry,
628 } => {
629 model.append_workspace_log(workspace_id, entry)?;
630 (
631 ControlResponse::Ack {
632 message: "workspace log appended".into(),
633 },
634 true,
635 )
636 }
637 ControlCommand::AgentClearLog { workspace_id } => {
638 model.clear_workspace_log(workspace_id)?;
639 (
640 ControlResponse::Ack {
641 message: "workspace log cleared".into(),
642 },
643 true,
644 )
645 }
646 ControlCommand::AgentCreateNotification {
647 target,
648 kind,
649 title,
650 subtitle,
651 external_id,
652 message,
653 state,
654 } => {
655 model.create_agent_notification(
656 target,
657 kind,
658 title,
659 subtitle,
660 external_id,
661 message,
662 state,
663 )?;
664 (
665 ControlResponse::Ack {
666 message: "agent notification created".into(),
667 },
668 true,
669 )
670 }
671 ControlCommand::OpenNotification {
672 window_id,
673 notification_id,
674 } => {
675 model
676 .open_notification(window_id.unwrap_or(model.active_window), notification_id)?;
677 (
678 ControlResponse::Ack {
679 message: "notification opened".into(),
680 },
681 true,
682 )
683 }
684 ControlCommand::ClearNotification { notification_id } => {
685 model.clear_notification(notification_id)?;
686 (
687 ControlResponse::Ack {
688 message: "notification cleared".into(),
689 },
690 true,
691 )
692 }
693 ControlCommand::MarkNotificationDelivery {
694 notification_id,
695 delivery,
696 } => {
697 model.mark_notification_delivery(notification_id, delivery)?;
698 (
699 ControlResponse::Ack {
700 message: "notification delivery updated".into(),
701 },
702 true,
703 )
704 }
705 ControlCommand::AgentClearNotifications { target } => {
706 model.clear_agent_notifications(target)?;
707 (
708 ControlResponse::Ack {
709 message: "agent notifications cleared".into(),
710 },
711 true,
712 )
713 }
714 ControlCommand::DismissSurfaceAlert {
715 workspace_id,
716 pane_id,
717 surface_id,
718 } => {
719 model.dismiss_surface_alert(workspace_id, pane_id, surface_id)?;
720 (
721 ControlResponse::Ack {
722 message: "surface alert dismissed".into(),
723 },
724 true,
725 )
726 }
727 ControlCommand::AgentTriggerFlash {
728 workspace_id,
729 pane_id,
730 surface_id,
731 } => {
732 model.trigger_surface_flash(workspace_id, pane_id, surface_id)?;
733 (
734 ControlResponse::Ack {
735 message: "surface flash triggered".into(),
736 },
737 true,
738 )
739 }
740 ControlCommand::AgentFocusLatestUnread { window_id } => {
741 model.focus_latest_unread(window_id.unwrap_or(model.active_window))?;
742 (
743 ControlResponse::Ack {
744 message: "focused latest unread activity".into(),
745 },
746 true,
747 )
748 }
749 ControlCommand::Browser { .. } => {
750 return Err(DomainError::InvalidOperation(
751 "browser automation commands require a live GTK host",
752 ));
753 }
754 ControlCommand::TerminalDebug { .. } => {
755 return Err(DomainError::InvalidOperation(
756 "terminal debug commands require a live GTK host",
757 ));
758 }
759 ControlCommand::QueryStatus { query } => match query {
760 ControlQuery::ActiveWindow | ControlQuery::All => (
761 ControlResponse::Status {
762 session: model.snapshot(),
763 },
764 false,
765 ),
766 ControlQuery::Window { window_id } => (window_snapshot(model, window_id)?, false),
767 ControlQuery::Workspace { workspace_id } => {
768 (workspace_snapshot(model, workspace_id)?, false)
769 }
770 ControlQuery::Identify {
771 workspace_id,
772 pane_id,
773 surface_id,
774 } => (
775 ControlResponse::Identify {
776 result: identify_snapshot(model, workspace_id, pane_id, surface_id)?,
777 },
778 false,
779 ),
780 },
781 };
782
783 if mutated {
784 state.revision = state.revision.saturating_add(1);
785 }
786
787 Ok(response)
788 }
789}
790
791fn window_snapshot(model: &AppModel, window_id: WindowId) -> Result<ControlResponse, DomainError> {
792 let _ = model
793 .windows
794 .get(&window_id)
795 .ok_or(DomainError::MissingWindow(window_id))?;
796 Ok(ControlResponse::Status {
797 session: model.snapshot(),
798 })
799}
800
801fn workspace_snapshot(
802 model: &AppModel,
803 workspace_id: WorkspaceId,
804) -> Result<ControlResponse, DomainError> {
805 let _ = model
806 .workspaces
807 .get(&workspace_id)
808 .ok_or(DomainError::MissingWorkspace(workspace_id))?;
809 Ok(ControlResponse::WorkspaceState {
810 workspace_id,
811 session: model.snapshot(),
812 })
813}
814
815fn identify_snapshot(
816 model: &AppModel,
817 workspace_id: Option<WorkspaceId>,
818 pane_id: Option<PaneId>,
819 surface_id: Option<SurfaceId>,
820) -> Result<IdentifyResult, DomainError> {
821 let focused = focused_identify_context(model)?;
822 let caller = if workspace_id.is_some() || pane_id.is_some() || surface_id.is_some() {
823 Some(resolve_identify_context(
824 model,
825 workspace_id,
826 pane_id,
827 surface_id,
828 )?)
829 } else {
830 None
831 };
832
833 Ok(IdentifyResult { focused, caller })
834}
835
836fn focused_identify_context(model: &AppModel) -> Result<IdentifyContext, DomainError> {
837 let window_id = model.active_window;
838 let workspace = model
839 .active_workspace()
840 .ok_or(DomainError::InvalidOperation("app has no active workspace"))?;
841 let pane = workspace
842 .panes
843 .get(&workspace.active_pane)
844 .ok_or(DomainError::MissingPane(workspace.active_pane))?;
845 let surface = pane
846 .surfaces
847 .get(&pane.active_surface)
848 .ok_or(DomainError::MissingSurface(pane.active_surface))?;
849
850 Ok(identify_context_from_parts(
851 window_id, workspace, pane.id, surface,
852 ))
853}
854
855fn resolve_identify_context(
856 model: &AppModel,
857 workspace_id: Option<WorkspaceId>,
858 pane_id: Option<PaneId>,
859 surface_id: Option<SurfaceId>,
860) -> Result<IdentifyContext, DomainError> {
861 let window_id = model.active_window;
862
863 if let Some(surface_id) = surface_id {
864 for (candidate_workspace_id, workspace) in &model.workspaces {
865 if workspace_id.is_some_and(|expected| expected != *candidate_workspace_id) {
866 continue;
867 }
868 for (candidate_pane_id, pane) in &workspace.panes {
869 if pane_id.is_some_and(|expected| expected != *candidate_pane_id) {
870 continue;
871 }
872 if let Some(surface) = pane.surfaces.get(&surface_id) {
873 return Ok(identify_context_from_parts(
874 window_id,
875 workspace,
876 *candidate_pane_id,
877 surface,
878 ));
879 }
880 }
881 }
882 return Err(DomainError::MissingSurface(surface_id));
883 }
884
885 if let Some(pane_id) = pane_id {
886 for (candidate_workspace_id, workspace) in &model.workspaces {
887 if workspace_id.is_some_and(|expected| expected != *candidate_workspace_id) {
888 continue;
889 }
890 if let Some(pane) = workspace.panes.get(&pane_id) {
891 let surface = pane
892 .surfaces
893 .get(&pane.active_surface)
894 .ok_or(DomainError::MissingSurface(pane.active_surface))?;
895 return Ok(identify_context_from_parts(
896 window_id, workspace, pane_id, surface,
897 ));
898 }
899 }
900 return Err(DomainError::MissingPane(pane_id));
901 }
902
903 if let Some(workspace_id) = workspace_id {
904 let workspace = model
905 .workspaces
906 .get(&workspace_id)
907 .ok_or(DomainError::MissingWorkspace(workspace_id))?;
908 let pane = workspace
909 .panes
910 .get(&workspace.active_pane)
911 .ok_or(DomainError::MissingPane(workspace.active_pane))?;
912 let surface = pane
913 .surfaces
914 .get(&pane.active_surface)
915 .ok_or(DomainError::MissingSurface(pane.active_surface))?;
916 return Ok(identify_context_from_parts(
917 window_id, workspace, pane.id, surface,
918 ));
919 }
920
921 focused_identify_context(model)
922}
923
924fn identify_context_from_parts(
925 window_id: WindowId,
926 workspace: &taskers_domain::Workspace,
927 pane_id: PaneId,
928 surface: &SurfaceRecord,
929) -> IdentifyContext {
930 IdentifyContext {
931 window_id,
932 workspace_id: workspace.id,
933 workspace_label: workspace.label.clone(),
934 workspace_window_id: workspace.window_for_pane(pane_id),
935 pane_id,
936 surface_id: surface.id,
937 surface_kind: surface.kind.clone(),
938 title: normalized_value(surface.metadata.title.as_deref()),
939 cwd: normalized_value(surface.metadata.cwd.as_deref()),
940 url: normalized_value(surface.metadata.url.as_deref()),
941 loading: matches!(surface.kind, PaneKind::Browser).then_some(false),
942 }
943}
944
945fn normalized_value(value: Option<&str>) -> Option<String> {
946 value
947 .map(str::trim)
948 .filter(|value| !value.is_empty())
949 .map(str::to_owned)
950}
951
952#[cfg(test)]
953mod tests {
954 use taskers_domain::{AppModel, PaneKind, SignalEvent, SignalKind};
955
956 use crate::{ControlCommand, ControlQuery, ControlResponse};
957
958 use super::InMemoryController;
959
960 #[test]
961 fn revision_increments_for_mutations_but_not_queries() {
962 let controller = InMemoryController::new(AppModel::new("Main"));
963 assert_eq!(controller.revision(), 0);
964
965 controller
966 .handle(ControlCommand::QueryStatus {
967 query: ControlQuery::All,
968 })
969 .expect("query status");
970 assert_eq!(controller.revision(), 0);
971
972 controller
973 .handle(ControlCommand::CreateWorkspace {
974 label: "Docs".into(),
975 })
976 .expect("create workspace");
977 assert_eq!(controller.revision(), 1);
978 assert_eq!(controller.snapshot().revision, 1);
979 }
980
981 #[test]
982 fn revision_increments_for_signal_mutations() {
983 let controller = InMemoryController::new(AppModel::new("Main"));
984 let snapshot = controller.snapshot();
985 let workspace = snapshot.model.active_workspace().expect("workspace");
986
987 controller
988 .handle(ControlCommand::EmitSignal {
989 workspace_id: workspace.id,
990 pane_id: workspace.active_pane,
991 surface_id: None,
992 event: SignalEvent::new("pty", SignalKind::Progress, Some("Running".into())),
993 })
994 .expect("emit signal");
995
996 assert_eq!(controller.revision(), 1);
997 }
998
999 #[test]
1000 fn surface_signals_follow_a_moved_surface_even_with_stale_pane_context() {
1001 let controller = InMemoryController::new(AppModel::new("Main"));
1002 let snapshot = controller.snapshot();
1003 let source_workspace = snapshot.model.active_workspace().expect("workspace");
1004 let source_workspace_id = source_workspace.id;
1005 let source_pane_id = source_workspace.active_pane;
1006
1007 controller
1008 .handle(ControlCommand::CreateSurface {
1009 workspace_id: source_workspace_id,
1010 pane_id: source_pane_id,
1011 kind: PaneKind::Browser,
1012 })
1013 .expect("create moved surface");
1014 let moved_surface_id = controller
1015 .snapshot()
1016 .model
1017 .workspaces
1018 .get(&source_workspace_id)
1019 .and_then(|workspace| workspace.panes.get(&source_pane_id))
1020 .map(|pane| pane.active_surface)
1021 .expect("moved surface");
1022
1023 controller
1024 .handle(ControlCommand::CreateWorkspace {
1025 label: "Docs".into(),
1026 })
1027 .expect("create target workspace");
1028 let target_workspace_id = controller
1029 .snapshot()
1030 .model
1031 .active_workspace_id()
1032 .expect("target workspace");
1033
1034 controller
1035 .handle(ControlCommand::MoveSurfaceToWorkspace {
1036 source_workspace_id,
1037 source_pane_id,
1038 surface_id: moved_surface_id,
1039 target_workspace_id,
1040 })
1041 .expect("move surface");
1042
1043 controller
1044 .handle(ControlCommand::EmitSignal {
1045 workspace_id: source_workspace_id,
1046 pane_id: source_pane_id,
1047 surface_id: Some(moved_surface_id),
1048 event: SignalEvent::new("pty", SignalKind::Progress, Some("Running".into())),
1049 })
1050 .expect("emit moved surface signal");
1051
1052 let snapshot = controller.snapshot();
1053 let target_surface = snapshot
1054 .model
1055 .workspaces
1056 .values()
1057 .flat_map(|workspace| {
1058 workspace.panes.values().flat_map(move |pane| {
1059 pane.surfaces
1060 .values()
1061 .map(move |surface| (workspace, pane, surface))
1062 })
1063 })
1064 .find(|(_, _, surface)| surface.id == moved_surface_id)
1065 .expect("target surface");
1066
1067 assert_eq!(target_surface.0.id, target_workspace_id);
1068 assert_eq!(
1069 target_surface.2.attention,
1070 taskers_domain::AttentionState::Busy
1071 );
1072 assert_eq!(
1073 snapshot
1074 .model
1075 .workspaces
1076 .get(&source_workspace_id)
1077 .and_then(|workspace| workspace.panes.get(&source_pane_id))
1078 .and_then(|pane| pane.active_surface())
1079 .map(|surface| surface.attention),
1080 Some(taskers_domain::AttentionState::Normal)
1081 );
1082 }
1083
1084 #[test]
1085 fn identify_returns_focused_context_and_optional_caller() {
1086 let controller = InMemoryController::new(AppModel::new("Main"));
1087 let snapshot = controller.snapshot();
1088 let workspace = snapshot.model.active_workspace().expect("workspace");
1089 let pane = workspace
1090 .panes
1091 .get(&workspace.active_pane)
1092 .expect("active pane");
1093 let surface = pane.active_surface().expect("active surface");
1094
1095 let response = controller
1096 .handle(ControlCommand::QueryStatus {
1097 query: ControlQuery::Identify {
1098 workspace_id: None,
1099 pane_id: None,
1100 surface_id: None,
1101 },
1102 })
1103 .expect("identify focused");
1104 let ControlResponse::Identify { result } = response else {
1105 panic!("unexpected identify response");
1106 };
1107 assert_eq!(result.focused.workspace_id, workspace.id);
1108 assert_eq!(result.focused.pane_id, workspace.active_pane);
1109 assert_eq!(result.focused.surface_id, surface.id);
1110 assert_eq!(result.focused.surface_kind, PaneKind::Terminal);
1111 assert!(result.caller.is_none());
1112
1113 let response = controller
1114 .handle(ControlCommand::QueryStatus {
1115 query: ControlQuery::Identify {
1116 workspace_id: Some(workspace.id),
1117 pane_id: Some(workspace.active_pane),
1118 surface_id: Some(surface.id),
1119 },
1120 })
1121 .expect("identify caller");
1122 let ControlResponse::Identify { result } = response else {
1123 panic!("unexpected identify response");
1124 };
1125 let caller = result.caller.expect("caller context");
1126 assert_eq!(caller.workspace_id, workspace.id);
1127 assert_eq!(caller.pane_id, workspace.active_pane);
1128 assert_eq!(caller.surface_id, surface.id);
1129 }
1130}