taskers_core/
app_state.rs1use 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}