1use std::ops::ControlFlow;
2
3use crate::{
4 config::{self, StartTogetherOptions},
5 errors::TogetherResult,
6 log, log_err,
7 manager::{self, ProcessAction},
8 process, t_println,
9 terminal::Terminal,
10 terminal_ext::TerminalExt,
11};
12
13#[derive(Default)]
14struct InputState {
15 requested_quit: bool,
16 awaiting_quit_command: bool,
17 last_command: Option<BufferedCommand>,
18}
19
20enum BufferedCommand {
21 Start(String),
22 Restart(String, process::ProcessId),
23}
24
25enum Key {
26 #[cfg(feature = "termion")]
27 CtrlC,
28 Char(char),
29}
30
31#[cfg(feature = "termion")]
32impl TryFrom<termion::event::Key> for Key {
33 type Error = ();
34
35 fn try_from(key: termion::event::Key) -> Result<Self, Self::Error> {
36 match key {
37 termion::event::Key::Ctrl('c') => Ok(Self::CtrlC),
38 termion::event::Key::Char(c) => Ok(Self::Char(c)),
39 _ => Err(()),
40 }
41 }
42}
43
44impl From<char> for Key {
45 fn from(c: char) -> Self {
46 Self::Char(c)
47 }
48}
49
50#[cfg(feature = "termion")]
51pub fn block_for_user_input(
52 start_opts: &StartTogetherOptions,
53 sender: manager::ProcessManagerHandle,
54) -> TogetherResult<()> {
55 use std::io::Write;
56 use termion::input::TermRead;
58
59 let mut state = InputState::default();
60
61 let mut stdout = std::io::stdout();
63 let stdin = std::io::stdin();
64
65 for k in stdin.keys() {
66 let Ok(k): Result<Key, ()> = k?.try_into() else {
67 continue;
68 };
69
70 match handle_key_press(k, &mut state, start_opts, &sender) {
71 Ok(ControlFlow::Continue(_)) => {
72 write!(stdout, "{}", termion::cursor::Show).unwrap();
73 stdout.flush().unwrap();
74 }
75 Ok(ControlFlow::Break(_)) => break,
76 Err(e) => {
77 log_err!("Unexpected error: {:?}", e);
78 }
79 }
80 }
81
82 drop(stdout);
83 Ok(())
84}
85
86#[cfg(not(feature = "termion"))]
87pub fn block_for_user_input(
88 start_opts: &StartTogetherOptions,
89 sender: manager::ProcessManagerHandle,
90) -> TogetherResult<()> {
91 let mut state = InputState::default();
92 let mut input = String::new();
93 loop {
94 std::io::stdin().read_line(&mut input)?;
95 let Some(key) = input.trim().chars().next() else {
96 continue;
97 };
98
99 match handle_key_press(key.into(), &mut state, start_opts, &sender) {
100 Ok(ControlFlow::Continue(_)) => {}
101 Ok(ControlFlow::Break(_)) => break,
102 Err(e) => {
103 log_err!("Unexpected error: {:?}", e);
104 }
105 }
106
107 input.clear();
108 }
109 Ok(())
110}
111
112fn handle_key_press(
113 key: Key,
114 state: &mut InputState,
115 start_opts: &StartTogetherOptions,
116 sender: &manager::ProcessManagerHandle,
117) -> TogetherResult<ControlFlow<()>> {
118 if state.requested_quit {
119 state.requested_quit = false;
120 state.awaiting_quit_command = true;
121 }
122
123 match key {
124 #[cfg(feature = "termion")]
125 Key::CtrlC => {
126 log!("Ctrl-C pressed, stopping all processes...");
127 sender
128 .send(ProcessAction::KillAll)
129 .expect("Could not send signal on channel.");
130 }
131 Key::Char('h') | Key::Char('?') => {
132 log!("[help]");
133 t_println!("together is a tool to run multiple commands in parallel selectively by an interactive prompt.");
134
135 t_println!();
136 t_println!("Press 't' to trigger a one-time run");
137 t_println!("Press '.' to re-trigger the last one-time run or restart action");
138 if let Some(last) = &state.last_command {
139 t_println!(
140 " (last command: [{}] {})",
141 match last {
142 BufferedCommand::Start(_) => "start",
143 BufferedCommand::Restart(_, _) => "restart",
144 },
145 match last {
146 BufferedCommand::Start(command) => command,
147 BufferedCommand::Restart(command, _) => command,
148 }
149 );
150 }
151 t_println!("Press 'b' to batch trigger commands by recipe");
152 t_println!("Press 'z' to switch to running a single recipe");
153 t_println!("Press 'k' to kill a running command");
154 t_println!("Press 'r' to restart a running command");
155 t_println!("Press 'l' to list all running commands");
156 t_println!("Press 'd' to dump the current configuration");
157 t_println!("Press 'h' or '?' to show this help message");
158 t_println!("Press 'q' to stop");
159 t_println!();
160
161 t_println!();
162 log!("[status]");
163 match sender.list() {
164 Ok(list) => {
165 t_println!("together is running {} commands in parallel:", list.len());
166 for command in list {
167 t_println!(" {}", command);
168 }
169 }
170 Err(_) => {
171 t_println!("together is running in an unknown state");
172 }
173 }
174 }
175 Key::Char('q') => {
176 if state.awaiting_quit_command {
177 log!("Quitting together...");
178 sender.send(ProcessAction::KillAll)?;
179 return Ok(ControlFlow::Break(()));
180 }
181
182 log!("Press 'q' again to quit together");
183 state.requested_quit = true;
184 return Ok(ControlFlow::Break(()));
185 }
186 Key::Char('l') => {
187 for command in sender.list()? {
188 t_println!("{}", command);
189 }
190 }
191 Key::Char('d') => {
192 let list = sender.list()?;
193 let running: Vec<_> = list.iter().map(|c| c.command()).collect();
194 let config = start_opts.config.clone();
195 let config = config.with_running(&running);
196 config::dump(&config)?;
197 }
198 Key::Char('k') => {
199 let list = sender.list()?;
200 let command = Terminal::select_single_process(
201 "Pick command to kill, or press 'q' to cancel",
202 &sender,
203 &list,
204 )?;
205 if let Some(command) = command {
206 sender.kill(command.clone())?;
207 }
208 }
209 Key::Char('K') => {
210 let list = sender.list()?;
211 let command = Terminal::select_single_process(
212 "Pick command to kill, or press 'q' to cancel",
213 &sender,
214 &list,
215 )?;
216 let signal = command.and_then(|_| {
217 Terminal::select_single(
218 "Pick signal to send, or press 'q' to cancel",
219 &["SIGINT", "SIGTERM", "SIGKILL"],
220 )
221 });
222 let target = signal
223 .and_then(|signal| match *signal {
224 "SIGINT" => Some(process::ProcessSignal::SIGINT),
225 "SIGTERM" => Some(process::ProcessSignal::SIGTERM),
226 "SIGKILL" => Some(process::ProcessSignal::SIGKILL),
227 _ => None,
228 })
229 .and_then(|signal| command.map(|command| (command, signal)));
230 if let Some((command, signal)) = target {
231 sender.send(ProcessAction::KillAdvanced(command.clone(), signal))?;
232 }
233 }
234 Key::Char('r') => {
235 let list = sender.list()?;
236 let command = Terminal::select_single_process(
237 "Pick command to restart, or press 'q' to cancel",
238 &sender,
239 &list,
240 )?;
241 if let Some(command) = command {
242 sender.send(ProcessAction::Kill(command.clone()))?;
243 let process_id = sender.spawn(command.command())?;
244 state.last_command = Some(BufferedCommand::Restart(
245 command.command().to_string(),
246 process_id,
247 ));
248 }
249 }
250 Key::Char('t') => {
251 let list = sender.list()?;
252 let command = Terminal::select_single_command_with_running(
253 "Pick command to run, or press 'q' to cancel",
254 &sender,
255 &start_opts.config.start_options.commands,
256 &list,
257 )?;
258 if let Some(command) = command {
259 sender.spawn(command)?;
260 state.last_command = Some(BufferedCommand::Start(command.to_string()));
261 }
262 }
263 Key::Char('.') => match &state.last_command {
264 Some(BufferedCommand::Start(command)) => {
265 sender.spawn(command)?;
266 }
267 Some(BufferedCommand::Restart(command, process_id)) => {
268 match sender.restart(process_id.clone(), &command)? {
269 Some(id) => {
270 let command = command.clone();
271 state.last_command = Some(BufferedCommand::Restart(command, id))
272 }
273 None => {
274 log_err!("Could not find process to restart");
275 }
276 };
277 }
278 _ => {
279 log!("No last command to re-trigger");
280 }
281 },
282 Key::Char('b') => {
283 let all_recipes = config::get_unique_recipes(&start_opts.config.start_options);
284 let all_recipes = all_recipes.into_iter().cloned().collect::<Vec<_>>();
285 let recipes = Terminal::select_multiple_recipes(
286 "Select one or more recipes to start running, or press 'q' to cancel",
287 &sender,
288 &all_recipes,
289 )?;
290 let commands =
291 config::collect_commands_by_recipes(&start_opts.config.start_options, &recipes);
292 for command in commands {
293 sender.send(ProcessAction::Create(command.clone()))?;
294 }
295 }
296 Key::Char('z') => {
297 let all_recipes = config::get_unique_recipes(&start_opts.config.start_options);
298 let all_recipes = all_recipes.into_iter().cloned().collect::<Vec<_>>();
299 let recipe = Terminal::select_single_recipe(
300 "Select a recipe to start running, or press 'q' to cancel (note: this will stop all other commands)",
301 &sender,
302 &all_recipes,
303 )?;
304 if let Some(recipe) = recipe {
305 let recipe = recipe.clone();
306 let recipe_commands = config::collect_commands_by_recipes(
307 &start_opts.config.start_options,
308 &[recipe],
309 );
310 let list = sender.list()?;
311 let kill_commands: Vec<_> = list
312 .iter()
313 .filter(|c| !recipe_commands.contains(&c.command().to_string()))
314 .collect();
315
316 for command in kill_commands {
317 sender.kill(command.clone())?;
318 }
319 for command in recipe_commands {
320 sender.spawn(&command)?;
321 }
322 }
323 }
324 Key::Char('\n') => {}
325 Key::Char(c) => {
326 log_err!("Unknown command: '{}'", c);
327 log!("Press 'h' or '?' for help");
328 }
329 }
330 state.awaiting_quit_command = false;
331
332 Ok(ControlFlow::Continue(()))
333}