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}