Skip to main content

taskers_core/
app_state.rs

1use std::{collections::BTreeMap, path::PathBuf};
2
3use anyhow::{Context, Result, anyhow};
4use taskers_control::{
5    ControlCommand, ControlResponse, InMemoryController, VcsCommand, VcsCommandResult,
6};
7use taskers_domain::{AppModel, BrowserProfileMode, PaneId, PaneKind, SurfaceId, WorkspaceId};
8use taskers_ghostty::{BackendChoice, GhosttyHostOptions, SurfaceDescriptor};
9use taskers_runtime::{ShellLaunchSpec, TerminalSessionClient};
10
11use crate::{
12    VcsService, pane_runtime::RuntimeManager, session_store,
13    terminal_session_manager::TerminalSessionManager,
14};
15
16#[derive(Clone)]
17pub struct AppState {
18    controller: InMemoryController,
19    runtime: RuntimeManager,
20    terminal_sessions: TerminalSessionManager,
21    backend: BackendChoice,
22    session_path: PathBuf,
23    shell_launch: ShellLaunchSpec,
24    vcs: VcsService,
25}
26
27impl AppState {
28    pub fn new(
29        model: AppModel,
30        session_path: PathBuf,
31        backend: BackendChoice,
32        shell_launch: ShellLaunchSpec,
33        terminal_session_client: Option<TerminalSessionClient>,
34    ) -> Result<Self> {
35        let controller = InMemoryController::new(model.clone());
36        let runtime = RuntimeManager::new(
37            controller.clone(),
38            !matches!(
39                backend,
40                BackendChoice::Ghostty | BackendChoice::GhosttyEmbedded
41            ),
42            shell_launch.clone(),
43        );
44        runtime.sync_model(&model)?;
45        let terminal_sessions = TerminalSessionManager::new(terminal_session_client);
46        terminal_sessions.sync_model(&model)?;
47
48        let state = Self {
49            controller,
50            runtime,
51            terminal_sessions,
52            backend,
53            session_path,
54            shell_launch,
55            vcs: VcsService::default(),
56        };
57        state.persist_snapshot()?;
58        Ok(state)
59    }
60
61    pub fn controller(&self) -> InMemoryController {
62        self.controller.clone()
63    }
64
65    pub fn runtime(&self) -> RuntimeManager {
66        self.runtime.clone()
67    }
68
69    pub fn backend(&self) -> BackendChoice {
70        self.backend
71    }
72
73    pub fn shell_launch(&self) -> &ShellLaunchSpec {
74        &self.shell_launch
75    }
76
77    pub fn ghostty_host_options(&self) -> GhosttyHostOptions {
78        GhosttyHostOptions::from_shell_launch(&self.shell_launch)
79    }
80
81    pub fn snapshot_model(&self) -> AppModel {
82        self.controller.snapshot().model
83    }
84
85    pub fn revision(&self) -> u64 {
86        self.controller.revision()
87    }
88
89    pub fn dispatch(&self, command: ControlCommand) -> Result<ControlResponse> {
90        if let ControlCommand::Vcs { vcs_command } = command {
91            return self.dispatch_vcs(vcs_command);
92        }
93        let response = self
94            .controller
95            .handle(command)
96            .map_err(|error| anyhow!(error.to_string()))?;
97        let model = self.snapshot_model();
98        self.runtime.sync_model(&model)?;
99        self.terminal_sessions.sync_model(&model)?;
100        self.persist_snapshot()?;
101        Ok(response)
102    }
103
104    fn dispatch_vcs(&self, command: VcsCommand) -> Result<ControlResponse> {
105        let model = self.snapshot_model();
106        let result = match self.vcs.execute(&model, command) {
107            Ok(result) => result,
108            Err(error) => VcsCommandResult {
109                snapshot: None,
110                message: Some(error.to_string()),
111            },
112        };
113        Ok(ControlResponse::Vcs { result })
114    }
115
116    pub fn persist_snapshot(&self) -> Result<()> {
117        let model = self.snapshot_model();
118        self.persist_model(&model)
119    }
120
121    pub fn persist_model(&self, model: &AppModel) -> Result<()> {
122        session_store::save_session(&self.session_path, model).with_context(|| {
123            format!(
124                "failed to save taskers session to {}",
125                self.session_path.display()
126            )
127        })
128    }
129
130    pub fn surface_descriptor_for_pane(
131        &self,
132        workspace_id: WorkspaceId,
133        pane_id: PaneId,
134    ) -> Result<SurfaceDescriptor> {
135        let model = self.snapshot_model();
136        let workspace = model
137            .workspaces
138            .get(&workspace_id)
139            .ok_or_else(|| anyhow!("workspace {workspace_id} is not present"))?;
140        let pane = workspace
141            .panes
142            .get(&pane_id)
143            .ok_or_else(|| anyhow!("pane {pane_id} is not present"))?;
144        let surface_id = pane
145            .active_surface()
146            .map(|surface| surface.id)
147            .ok_or_else(|| anyhow!("pane {pane_id} has no active surface"))?;
148
149        self.surface_descriptor_for_surface_in_model(&model, workspace_id, pane_id, surface_id)
150    }
151
152    pub fn surface_descriptor_for_surface(
153        &self,
154        workspace_id: WorkspaceId,
155        pane_id: PaneId,
156        surface_id: SurfaceId,
157    ) -> Result<SurfaceDescriptor> {
158        let model = self.snapshot_model();
159        self.surface_descriptor_for_surface_in_model(&model, workspace_id, pane_id, surface_id)
160    }
161
162    fn surface_descriptor_for_surface_in_model(
163        &self,
164        model: &AppModel,
165        workspace_id: WorkspaceId,
166        pane_id: PaneId,
167        surface_id: SurfaceId,
168    ) -> Result<SurfaceDescriptor> {
169        let workspace = model
170            .workspaces
171            .get(&workspace_id)
172            .ok_or_else(|| anyhow!("workspace {workspace_id} is not present"))?;
173        let pane = workspace
174            .panes
175            .get(&pane_id)
176            .ok_or_else(|| anyhow!("pane {pane_id} is not present"))?;
177        let surface = pane
178            .surfaces
179            .get(&surface_id)
180            .ok_or_else(|| anyhow!("surface {surface_id} is not present in pane {pane_id}"))?;
181
182        let env = match surface.kind {
183            PaneKind::Terminal => {
184                let mut env = self.shell_launch.env.clone();
185                env.insert("TASKERS_PANE_ID".into(), pane.id.to_string());
186                env.insert("TASKERS_WORKSPACE_ID".into(), workspace_id.to_string());
187                env.insert("TASKERS_SURFACE_ID".into(), surface.id.to_string());
188                env.insert(
189                    "TASKERS_AGENT_SESSION_ID".into(),
190                    surface.session_id.to_string(),
191                );
192                env.insert(
193                    "TASKERS_TERMINAL_SESSION_ID".into(),
194                    surface.session_id.to_string(),
195                );
196                env
197            }
198            PaneKind::Browser => BTreeMap::new(),
199        };
200
201        Ok(SurfaceDescriptor {
202            cols: 120,
203            rows: 40,
204            kind: surface.kind.clone(),
205            cwd: surface.metadata.cwd.clone(),
206            title: surface.metadata.title.clone(),
207            url: surface.metadata.url.clone(),
208            browser_profile_mode: match surface.kind {
209                PaneKind::Browser => surface.metadata.browser_profile_mode,
210                PaneKind::Terminal => BrowserProfileMode::PersistentDefault,
211            },
212            command_argv: Vec::new(),
213            env,
214        })
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use std::path::PathBuf;
221
222    use taskers_control::{ControlCommand, ControlQuery};
223    use taskers_domain::{AppModel, BrowserProfileMode, PaneKind, PaneMetadataPatch};
224    use taskers_ghostty::{BackendChoice, GhosttyHostOptions};
225    use taskers_runtime::ShellLaunchSpec;
226
227    use super::AppState;
228
229    #[test]
230    fn surface_descriptor_keeps_surface_metadata_but_omits_embedded_command_override() {
231        let model = AppModel::new("Main");
232        let workspace = model.active_workspace_id().expect("workspace");
233        let pane = model.active_workspace().expect("workspace").active_pane;
234
235        let mut shell_launch = ShellLaunchSpec::fallback();
236        shell_launch.program = PathBuf::from("/bin/zsh");
237        shell_launch.args = vec!["-i".into()];
238        shell_launch
239            .env
240            .insert("TASKERS_SOCKET".into(), "/tmp/taskers.sock".into());
241
242        let app_state = AppState::new(
243            model,
244            PathBuf::from("/tmp/taskers-session.json"),
245            BackendChoice::Mock,
246            shell_launch,
247            None,
248        )
249        .expect("app state");
250
251        let descriptor = app_state
252            .surface_descriptor_for_pane(workspace, pane)
253            .expect("descriptor");
254
255        assert_eq!(descriptor.kind, PaneKind::Terminal);
256        assert_eq!(descriptor.url, None);
257        assert!(descriptor.command_argv.is_empty());
258        assert_eq!(
259            descriptor.env.get("TASKERS_WORKSPACE_ID"),
260            Some(&workspace.to_string())
261        );
262        assert_eq!(
263            descriptor.env.get("TASKERS_PANE_ID"),
264            Some(&pane.to_string())
265        );
266        assert!(descriptor.env.contains_key("TASKERS_SURFACE_ID"));
267        assert!(descriptor.env.contains_key("TASKERS_AGENT_SESSION_ID"));
268        assert!(descriptor.env.contains_key("TASKERS_TERMINAL_SESSION_ID"));
269    }
270
271    #[test]
272    fn surface_descriptor_for_surface_keeps_target_ids_for_inactive_tabs() {
273        let mut model = AppModel::new("Main");
274        let workspace = model.active_workspace_id().expect("workspace");
275        let pane = model.active_workspace().expect("workspace").active_pane;
276        let first_surface = model
277            .active_workspace()
278            .and_then(|workspace| workspace.panes.get(&pane))
279            .and_then(|pane| pane.active_surface())
280            .map(|surface| surface.id)
281            .expect("first surface");
282        let second_surface = model
283            .create_surface(workspace, pane, PaneKind::Terminal)
284            .expect("second surface");
285        model
286            .focus_surface(workspace, pane, first_surface)
287            .expect("restore active surface");
288
289        let mut shell_launch = ShellLaunchSpec::fallback();
290        shell_launch.program = PathBuf::from("/bin/zsh");
291        shell_launch.args = vec!["-i".into()];
292        shell_launch
293            .env
294            .insert("TASKERS_SOCKET".into(), "/tmp/taskers.sock".into());
295
296        let app_state = AppState::new(
297            model,
298            PathBuf::from("/tmp/taskers-session.json"),
299            BackendChoice::Mock,
300            shell_launch,
301            None,
302        )
303        .expect("app state");
304
305        let descriptor = app_state
306            .surface_descriptor_for_surface(workspace, pane, second_surface)
307            .expect("descriptor");
308
309        assert_eq!(descriptor.kind, PaneKind::Terminal);
310        assert_eq!(
311            descriptor.env.get("TASKERS_WORKSPACE_ID"),
312            Some(&workspace.to_string())
313        );
314        assert_eq!(
315            descriptor.env.get("TASKERS_PANE_ID"),
316            Some(&pane.to_string())
317        );
318        assert_eq!(
319            descriptor.env.get("TASKERS_SURFACE_ID"),
320            Some(&second_surface.to_string())
321        );
322    }
323
324    #[test]
325    fn ghostty_host_options_follow_shell_launch() {
326        let model = AppModel::new("Main");
327
328        let mut shell_launch = ShellLaunchSpec::fallback();
329        shell_launch.program = PathBuf::from("/bin/zsh");
330        shell_launch.args = vec!["-i".into()];
331        shell_launch
332            .env
333            .insert("TASKERS_SOCKET".into(), "/tmp/taskers.sock".into());
334
335        let app_state = AppState::new(
336            model,
337            PathBuf::from("/tmp/taskers-session.json"),
338            BackendChoice::Mock,
339            shell_launch,
340            None,
341        )
342        .expect("app state");
343
344        let options: GhosttyHostOptions = app_state.ghostty_host_options();
345        assert_eq!(options.command_argv, vec!["/bin/zsh", "-i"]);
346        assert_eq!(
347            options.env.get("TASKERS_SOCKET").map(String::as_str),
348            Some("/tmp/taskers.sock")
349        );
350    }
351
352    #[test]
353    fn browser_surface_descriptor_omits_shell_launch_and_keeps_url() {
354        let mut model = AppModel::new("Main");
355        let workspace = model.active_workspace_id().expect("workspace");
356        let pane = model.active_workspace().expect("workspace").active_pane;
357        let surface = model
358            .create_surface(workspace, pane, PaneKind::Browser)
359            .expect("browser surface");
360        model
361            .update_surface_metadata(
362                surface,
363                PaneMetadataPatch {
364                    title: Some("Taskers".into()),
365                    cwd: None,
366                    url: Some("https://example.com".into()),
367                    browser_profile_mode: Some(BrowserProfileMode::Ephemeral),
368                    repo_name: None,
369                    git_branch: None,
370                    ports: None,
371                    agent_kind: None,
372                },
373            )
374            .expect("metadata updated");
375
376        let app_state = AppState::new(
377            model,
378            PathBuf::from("/tmp/taskers-session.json"),
379            BackendChoice::Mock,
380            ShellLaunchSpec::fallback(),
381            None,
382        )
383        .expect("app state");
384
385        let descriptor = app_state
386            .surface_descriptor_for_pane(workspace, pane)
387            .expect("descriptor");
388
389        assert_eq!(descriptor.kind, PaneKind::Browser);
390        assert_eq!(descriptor.url.as_deref(), Some("https://example.com"));
391        assert_eq!(
392            descriptor.browser_profile_mode,
393            BrowserProfileMode::Ephemeral
394        );
395        assert!(descriptor.command_argv.is_empty());
396        assert!(descriptor.env.is_empty());
397    }
398
399    #[test]
400    fn revision_tracks_controller_mutations() {
401        let app_state = AppState::new(
402            AppModel::new("Main"),
403            PathBuf::from("/tmp/taskers-session.json"),
404            BackendChoice::Mock,
405            ShellLaunchSpec::fallback(),
406            None,
407        )
408        .expect("app state");
409
410        assert_eq!(app_state.revision(), 0);
411
412        app_state
413            .dispatch(ControlCommand::QueryStatus {
414                query: ControlQuery::All,
415            })
416            .expect("query");
417        assert_eq!(app_state.revision(), 0);
418
419        app_state
420            .dispatch(ControlCommand::CreateWorkspace {
421                label: "Docs".into(),
422            })
423            .expect("create workspace");
424        assert_eq!(app_state.revision(), 1);
425    }
426
427    #[test]
428    fn embedded_backend_disables_mock_runtime() {
429        let model = AppModel::new("Main");
430        let workspace = model.active_workspace().expect("workspace");
431        let pane = workspace.panes.get(&workspace.active_pane).expect("pane");
432        let surface_id = pane.active_surface;
433
434        let app_state = AppState::new(
435            model,
436            PathBuf::from("/tmp/taskers-session.json"),
437            BackendChoice::GhosttyEmbedded,
438            ShellLaunchSpec::fallback(),
439            None,
440        )
441        .expect("app state");
442
443        assert_eq!(app_state.backend(), BackendChoice::GhosttyEmbedded);
444        assert_eq!(app_state.revision(), 0);
445        assert!(app_state.runtime().snapshot(surface_id).is_none());
446    }
447}