Skip to main content

taskers_core/
app_state.rs

1use std::path::PathBuf;
2
3use anyhow::{Context, Result, anyhow};
4use taskers_control::{ControlCommand, ControlResponse, InMemoryController};
5use taskers_domain::{AppModel, PaneId, WorkspaceId};
6use taskers_ghostty::{BackendChoice, SurfaceDescriptor};
7use taskers_runtime::ShellLaunchSpec;
8
9use crate::{pane_runtime::RuntimeManager, session_store};
10
11#[derive(Clone)]
12pub struct AppState {
13    controller: InMemoryController,
14    runtime: RuntimeManager,
15    backend: BackendChoice,
16    session_path: PathBuf,
17    shell_launch: ShellLaunchSpec,
18}
19
20impl AppState {
21    pub fn new(
22        model: AppModel,
23        session_path: PathBuf,
24        backend: BackendChoice,
25        shell_launch: ShellLaunchSpec,
26    ) -> Result<Self> {
27        let controller = InMemoryController::new(model.clone());
28        let runtime = RuntimeManager::new(
29            controller.clone(),
30            !matches!(
31                backend,
32                BackendChoice::Ghostty | BackendChoice::GhosttyEmbedded
33            ),
34            shell_launch.clone(),
35        );
36        runtime.sync_model(&model)?;
37
38        let state = Self {
39            controller,
40            runtime,
41            backend,
42            session_path,
43            shell_launch,
44        };
45        state.persist_snapshot()?;
46        Ok(state)
47    }
48
49    pub fn controller(&self) -> InMemoryController {
50        self.controller.clone()
51    }
52
53    pub fn runtime(&self) -> RuntimeManager {
54        self.runtime.clone()
55    }
56
57    pub fn backend(&self) -> BackendChoice {
58        self.backend
59    }
60
61    pub fn shell_launch(&self) -> &ShellLaunchSpec {
62        &self.shell_launch
63    }
64
65    pub fn snapshot_model(&self) -> AppModel {
66        self.controller.snapshot().model
67    }
68
69    pub fn revision(&self) -> u64 {
70        self.controller.revision()
71    }
72
73    pub fn dispatch(&self, command: ControlCommand) -> Result<ControlResponse> {
74        let response = self
75            .controller
76            .handle(command)
77            .map_err(|error| anyhow!(error.to_string()))?;
78        self.runtime.sync_model(&self.snapshot_model())?;
79        self.persist_snapshot()?;
80        Ok(response)
81    }
82
83    pub fn persist_snapshot(&self) -> Result<()> {
84        let model = self.snapshot_model();
85        self.persist_model(&model)
86    }
87
88    pub fn persist_model(&self, model: &AppModel) -> Result<()> {
89        session_store::save_session(&self.session_path, model).with_context(|| {
90            format!(
91                "failed to save taskers session to {}",
92                self.session_path.display()
93            )
94        })
95    }
96
97    pub fn surface_descriptor_for_pane(
98        &self,
99        workspace_id: WorkspaceId,
100        pane_id: PaneId,
101    ) -> Result<SurfaceDescriptor> {
102        let model = self.snapshot_model();
103        let workspace = model
104            .workspaces
105            .get(&workspace_id)
106            .ok_or_else(|| anyhow!("workspace {workspace_id} is not present"))?;
107        let pane = workspace
108            .panes
109            .get(&pane_id)
110            .ok_or_else(|| anyhow!("pane {pane_id} is not present"))?;
111        let surface = pane
112            .active_surface()
113            .ok_or_else(|| anyhow!("pane {pane_id} has no active surface"))?;
114
115        let mut env = self.shell_launch.env.clone();
116        env.insert("TASKERS_PANE_ID".into(), pane.id.to_string());
117        env.insert("TASKERS_WORKSPACE_ID".into(), workspace_id.to_string());
118        env.insert("TASKERS_SURFACE_ID".into(), surface.id.to_string());
119
120        Ok(SurfaceDescriptor {
121            cols: 120,
122            rows: 40,
123            cwd: surface.metadata.cwd.clone(),
124            title: surface.metadata.title.clone(),
125            command_argv: self.shell_launch.program_and_args(),
126            env,
127        })
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use std::path::PathBuf;
134
135    use taskers_control::{ControlCommand, ControlQuery};
136    use taskers_domain::AppModel;
137    use taskers_ghostty::BackendChoice;
138    use taskers_runtime::ShellLaunchSpec;
139
140    use super::AppState;
141
142    #[test]
143    fn surface_descriptor_includes_shell_launch_and_surface_metadata() {
144        let model = AppModel::new("Main");
145        let workspace = model.active_workspace_id().expect("workspace");
146        let pane = model.active_workspace().expect("workspace").active_pane;
147
148        let mut shell_launch = ShellLaunchSpec::fallback();
149        shell_launch.program = PathBuf::from("/bin/zsh");
150        shell_launch.args = vec!["-i".into()];
151        shell_launch
152            .env
153            .insert("TASKERS_SOCKET".into(), "/tmp/taskers.sock".into());
154
155        let app_state = AppState::new(
156            model,
157            PathBuf::from("/tmp/taskers-session.json"),
158            BackendChoice::Mock,
159            shell_launch,
160        )
161        .expect("app state");
162
163        let descriptor = app_state
164            .surface_descriptor_for_pane(workspace, pane)
165            .expect("descriptor");
166
167        assert_eq!(descriptor.command_argv, vec!["/bin/zsh", "-i"]);
168        assert_eq!(
169            descriptor.env.get("TASKERS_WORKSPACE_ID"),
170            Some(&workspace.to_string())
171        );
172        assert_eq!(
173            descriptor.env.get("TASKERS_PANE_ID"),
174            Some(&pane.to_string())
175        );
176        assert!(descriptor.env.contains_key("TASKERS_SURFACE_ID"));
177    }
178
179    #[test]
180    fn revision_tracks_controller_mutations() {
181        let app_state = AppState::new(
182            AppModel::new("Main"),
183            PathBuf::from("/tmp/taskers-session.json"),
184            BackendChoice::Mock,
185            ShellLaunchSpec::fallback(),
186        )
187        .expect("app state");
188
189        assert_eq!(app_state.revision(), 0);
190
191        app_state
192            .dispatch(ControlCommand::QueryStatus {
193                query: ControlQuery::All,
194            })
195            .expect("query");
196        assert_eq!(app_state.revision(), 0);
197
198        app_state
199            .dispatch(ControlCommand::CreateWorkspace {
200                label: "Docs".into(),
201            })
202            .expect("create workspace");
203        assert_eq!(app_state.revision(), 1);
204    }
205
206    #[test]
207    fn embedded_backend_disables_mock_runtime() {
208        let model = AppModel::new("Main");
209        let workspace = model.active_workspace().expect("workspace");
210        let pane = workspace.panes.get(&workspace.active_pane).expect("pane");
211        let surface_id = pane.active_surface;
212
213        let app_state = AppState::new(
214            model,
215            PathBuf::from("/tmp/taskers-session.json"),
216            BackendChoice::GhosttyEmbedded,
217            ShellLaunchSpec::fallback(),
218        )
219        .expect("app state");
220
221        assert_eq!(app_state.backend(), BackendChoice::GhosttyEmbedded);
222        assert_eq!(app_state.revision(), 0);
223        assert!(app_state.runtime().snapshot(surface_id).is_none());
224    }
225}