Skip to main content

taskers_control/
controller.rs

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}