Skip to main content

par_term_scripting/
manager.rs

1//! Per-tab multi-script orchestrator.
2//!
3//! [`ScriptManager`] manages multiple [`ScriptProcess`] instances for a single tab,
4//! providing lifecycle management, event broadcasting, and panel state tracking.
5
6use std::collections::HashMap;
7
8use super::process::ScriptProcess;
9use super::protocol::{ScriptCommand, ScriptEvent};
10use par_term_config::ScriptConfig;
11
12/// Unique identifier for a managed script process.
13pub type ScriptId = u64;
14
15/// Default maximum `WriteText` writes per second.
16pub const DEFAULT_WRITE_TEXT_RATE: u32 = 10;
17/// Default maximum `RunCommand` executions per second.
18pub const DEFAULT_RUN_COMMAND_RATE: u32 = 1;
19
20/// Manages multiple script subprocess instances for a single tab.
21///
22/// Each script is assigned a unique [`ScriptId`] and can be individually started,
23/// stopped, and communicated with. Supports panel state tracking per script and
24/// event broadcasting to all running scripts.
25pub struct ScriptManager {
26    /// Next ID to assign to a new script process.
27    next_id: ScriptId,
28    /// Map of active script processes keyed by their assigned ID.
29    processes: HashMap<ScriptId, ScriptProcess>,
30    /// Panel state per script: script_id -> (title, content).
31    panels: HashMap<ScriptId, (String, String)>,
32    /// Last `WriteText` dispatch time per script (for rate limiting).
33    write_text_times: HashMap<ScriptId, std::time::Instant>,
34    /// Last `RunCommand` dispatch time per script (for rate limiting).
35    run_command_times: HashMap<ScriptId, std::time::Instant>,
36}
37
38impl ScriptManager {
39    /// Create a new empty `ScriptManager`.
40    pub fn new() -> Self {
41        Self {
42            next_id: 1,
43            processes: HashMap::new(),
44            panels: HashMap::new(),
45            write_text_times: HashMap::new(),
46            run_command_times: HashMap::new(),
47        }
48    }
49
50    /// Start a script subprocess from the given configuration.
51    ///
52    /// If `script_path` ends with `.py`, the command is `python3` with the script path
53    /// prepended to the args. Otherwise, `script_path` is used as the command directly.
54    ///
55    /// Returns the assigned [`ScriptId`] on success.
56    ///
57    /// # Errors
58    /// Returns an error string if the subprocess cannot be spawned.
59    pub fn start_script(&mut self, config: &ScriptConfig) -> Result<ScriptId, String> {
60        let (command, args) = if config.script_path.ends_with(".py") {
61            let mut full_args = vec![config.script_path.as_str()];
62            let arg_refs: Vec<&str> = config.args.iter().map(String::as_str).collect();
63            full_args.extend(arg_refs);
64            (
65                "python3".to_string(),
66                full_args.into_iter().map(String::from).collect::<Vec<_>>(),
67            )
68        } else {
69            let arg_refs: Vec<String> = config.args.to_vec();
70            (config.script_path.clone(), arg_refs)
71        };
72
73        let arg_strs: Vec<&str> = args.iter().map(String::as_str).collect();
74        let process = ScriptProcess::spawn(&command, &arg_strs, &config.env_vars)?;
75
76        let id = self.next_id;
77        self.next_id += 1;
78        self.processes.insert(id, process);
79
80        Ok(id)
81    }
82
83    /// Check if a script with the given ID is still running.
84    ///
85    /// Returns `false` if the script ID is unknown or the process has exited.
86    pub fn is_running(&mut self, id: ScriptId) -> bool {
87        self.processes.get_mut(&id).is_some_and(|p| p.is_running())
88    }
89
90    /// Send a [`ScriptEvent`] to a specific script by ID.
91    ///
92    /// # Errors
93    /// Returns an error if the script ID is unknown or the write fails.
94    pub fn send_event(&mut self, id: ScriptId, event: &ScriptEvent) -> Result<(), String> {
95        let process = self
96            .processes
97            .get_mut(&id)
98            .ok_or_else(|| format!("No script with id {}", id))?;
99        process.send_event(event)
100    }
101
102    /// Broadcast a [`ScriptEvent`] to all running scripts.
103    ///
104    /// Errors on individual scripts are silently ignored; the event is sent on a
105    /// best-effort basis to all processes.
106    pub fn broadcast_event(&mut self, event: &ScriptEvent) {
107        for process in self.processes.values_mut() {
108            let _ = process.send_event(event);
109        }
110    }
111
112    /// Drain pending [`ScriptCommand`]s from a specific script's stdout buffer.
113    ///
114    /// Returns an empty `Vec` if the script ID is unknown.
115    pub fn read_commands(&self, id: ScriptId) -> Vec<ScriptCommand> {
116        self.processes
117            .get(&id)
118            .map(|p| p.read_commands())
119            .unwrap_or_default()
120    }
121
122    /// Drain pending error lines from a specific script's stderr buffer.
123    ///
124    /// Returns an empty `Vec` if the script ID is unknown.
125    pub fn read_errors(&self, id: ScriptId) -> Vec<String> {
126        self.processes
127            .get(&id)
128            .map(|p| p.read_errors())
129            .unwrap_or_default()
130    }
131
132    /// Stop and remove a specific script by ID.
133    ///
134    /// Also clears the associated panel state and rate-limit tracking.
135    /// Does nothing if the ID is unknown.
136    pub fn stop_script(&mut self, id: ScriptId) {
137        if let Some(mut process) = self.processes.remove(&id) {
138            process.stop();
139        }
140        self.panels.remove(&id);
141        self.write_text_times.remove(&id);
142        self.run_command_times.remove(&id);
143    }
144
145    /// Stop and remove all managed scripts.
146    pub fn stop_all(&mut self) {
147        for (_, mut process) in self.processes.drain() {
148            process.stop();
149        }
150        self.panels.clear();
151        self.write_text_times.clear();
152        self.run_command_times.clear();
153    }
154
155    /// Check whether a `WriteText` command from `id` is within rate limits.
156    ///
157    /// Returns `true` (allowed) if at least `1000 / limit_per_sec` ms have
158    /// elapsed since the last allowed write. Updates the last-write timestamp
159    /// on success. `limit_per_sec == 0` uses the [`DEFAULT_WRITE_TEXT_RATE`].
160    pub fn check_write_text_rate(&mut self, id: ScriptId, limit_per_sec: u32) -> bool {
161        let rate = if limit_per_sec == 0 {
162            DEFAULT_WRITE_TEXT_RATE
163        } else {
164            limit_per_sec
165        };
166        let min_interval_ms = 1000u64 / rate as u64;
167        let now = std::time::Instant::now();
168        if self
169            .write_text_times
170            .get(&id)
171            .is_some_and(|last| (now.duration_since(*last).as_millis() as u64) < min_interval_ms)
172        {
173            return false;
174        }
175        self.write_text_times.insert(id, now);
176        true
177    }
178
179    /// Check whether a `RunCommand` from `id` is within rate limits.
180    ///
181    /// Returns `true` (allowed) if at least `1000 / limit_per_sec` ms have
182    /// elapsed since the last allowed run. Updates the last-run timestamp on
183    /// success. `limit_per_sec == 0` uses the [`DEFAULT_RUN_COMMAND_RATE`].
184    pub fn check_run_command_rate(&mut self, id: ScriptId, limit_per_sec: u32) -> bool {
185        let rate = if limit_per_sec == 0 {
186            DEFAULT_RUN_COMMAND_RATE
187        } else {
188            limit_per_sec
189        };
190        let min_interval_ms = 1000u64 / rate as u64;
191        let now = std::time::Instant::now();
192        if self
193            .run_command_times
194            .get(&id)
195            .is_some_and(|last| (now.duration_since(*last).as_millis() as u64) < min_interval_ms)
196        {
197            return false;
198        }
199        self.run_command_times.insert(id, now);
200        true
201    }
202
203    /// Get the panel state for a script.
204    ///
205    /// Returns `None` if the script ID has no panel set.
206    pub fn get_panel(&self, id: ScriptId) -> Option<&(String, String)> {
207        self.panels.get(&id)
208    }
209
210    /// Set the panel state (title, content) for a script.
211    pub fn set_panel(&mut self, id: ScriptId, title: String, content: String) {
212        self.panels.insert(id, (title, content));
213    }
214
215    /// Clear the panel state for a script.
216    pub fn clear_panel(&mut self, id: ScriptId) {
217        self.panels.remove(&id);
218    }
219
220    /// Get the IDs of all currently managed scripts.
221    pub fn script_ids(&self) -> Vec<ScriptId> {
222        self.processes.keys().copied().collect()
223    }
224}
225
226impl Default for ScriptManager {
227    fn default() -> Self {
228        Self::new()
229    }
230}
231
232impl Drop for ScriptManager {
233    fn drop(&mut self) {
234        self.stop_all();
235    }
236}