1pub mod busy;
13mod input;
14pub mod keys;
15pub mod profile;
16pub mod session;
17
18pub use input::{allow_quit, handle_key, run_command};
19
20use std::path::Path;
21
22use tokio::sync::mpsc;
23
24use crate::config::TerminalConfig;
25use crate::workers::WorkerMsg;
26use session::Session;
27
28pub const MIN_PANEL_ROWS: u16 = 5;
30pub const MIN_LIST_ROWS: u16 = 5;
32
33pub const TERMINAL_ACTIONS: &[&str] = &[
35 "toggle",
36 "focus",
37 "next_shell",
38 "prev_shell",
39 "scroll_up",
40 "scroll_down",
41];
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum ConfirmPolicy {
47 WhenBusy,
49 Always,
51 Never,
53}
54
55pub const CONFIRM_REASON: &str = "must be when_busy, always or never";
57
58impl ConfirmPolicy {
59 #[must_use]
61 pub fn parse(value: &str) -> Option<Self> {
62 match value.trim().to_ascii_lowercase().as_str() {
63 "when_busy" => Some(Self::WhenBusy),
64 "always" => Some(Self::Always),
65 "never" => Some(Self::Never),
66 _ => None,
67 }
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum TermCommand {
74 Toggle,
76 New(Option<String>),
78 Close,
80 Select(usize),
82 Max,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum Confirm {
89 Quit {
92 cancel: bool,
94 prompt: String,
96 },
97 Close {
99 prompt: String,
101 },
102}
103
104impl Confirm {
105 #[must_use]
107 pub fn prompt(&self) -> &str {
108 match self {
109 Self::Quit { prompt, .. } | Self::Close { prompt } => prompt,
110 }
111 }
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum KeyOutcome {
117 NotMine,
119 Handled,
121 Quit {
123 cancel: bool,
125 },
126}
127
128#[derive(Debug, Default)]
130pub struct TerminalPanel {
131 sessions: Vec<Session>,
132 active: usize,
133 visible: bool,
134 shell_focused: bool,
135 maximized: bool,
136 next_id: u64,
137 pending: Option<Confirm>,
138 output_pending: bool,
139 notify: Option<mpsc::Sender<WorkerMsg>>,
140}
141
142impl TerminalPanel {
143 pub fn attach(&mut self, notify: mpsc::Sender<WorkerMsg>) {
146 self.notify = Some(notify);
147 }
148
149 #[must_use]
151 pub fn is_visible(&self) -> bool {
152 self.visible
153 }
154
155 #[must_use]
157 pub fn shell_focused(&self) -> bool {
158 self.visible && self.shell_focused
159 }
160
161 #[must_use]
163 pub fn is_maximized(&self) -> bool {
164 self.maximized
165 }
166
167 #[must_use]
169 pub fn sessions(&self) -> &[Session] {
170 &self.sessions
171 }
172
173 #[must_use]
175 pub fn active(&self) -> usize {
176 self.active
177 }
178
179 #[must_use]
181 pub fn pending(&self) -> Option<&Confirm> {
182 self.pending.as_ref()
183 }
184
185 pub fn resize_all(&mut self, size: (u16, u16)) {
188 for session in &mut self.sessions {
189 session.resize(size);
190 }
191 }
192
193 pub fn note_output(&mut self) {
201 if self.visible {
202 self.output_pending = true;
203 }
204 }
205
206 #[must_use]
208 pub fn output_pending(&self) -> bool {
209 self.output_pending
210 }
211
212 pub fn frame_drawn(&mut self) {
215 self.output_pending = false;
216 for session in &self.sessions {
217 session.output_drawn();
218 }
219 }
220
221 pub fn on_exit(&mut self, id: u64) -> bool {
223 let Some(index) = self.sessions.iter().position(|s| s.id() == id) else {
224 return false;
225 };
226 self.sessions.remove(index);
227 self.after_removal(index);
228 true
229 }
230
231 fn after_removal(&mut self, index: usize) {
232 if self.sessions.is_empty() {
233 self.visible = false;
234 self.shell_focused = false;
235 self.maximized = false;
236 self.active = 0;
237 if matches!(self.pending, Some(Confirm::Close { .. })) {
238 self.pending = None;
239 }
240 return;
241 }
242 if index < self.active || self.active >= self.sessions.len() {
243 self.active = self.active.saturating_sub(1);
244 }
245 }
246
247 pub fn new_shell(
255 &mut self,
256 config: &TerminalConfig,
257 profile_name: Option<&str>,
258 cwd: &Path,
259 ) -> Result<(), String> {
260 let profile = profile::resolve(config, profile_name)?;
261 let notify = self
262 .notify
263 .clone()
264 .ok_or_else(|| "the terminal panel is not available".to_owned())?;
265 self.next_id += 1;
266 let size = initial_size(config.height, self.maximized);
267 let session = Session::spawn(
268 self.next_id,
269 &profile.name,
270 &profile.argv,
271 cwd,
272 size,
273 notify,
274 )?;
275 self.sessions.push(session);
276 self.active = self.sessions.len() - 1;
277 self.visible = true;
278 self.shell_focused = true;
279 Ok(())
280 }
281
282 pub fn toggle(&mut self, config: &TerminalConfig, cwd: &Path) -> Result<(), String> {
289 if self.visible {
290 self.visible = false;
291 self.shell_focused = false;
292 self.maximized = false;
293 return Ok(());
294 }
295 self.show(config, cwd)
296 }
297
298 pub fn switch_focus(&mut self, config: &TerminalConfig, cwd: &Path) -> Result<(), String> {
305 if !self.visible {
306 return self.show(config, cwd);
307 }
308 self.shell_focused = !self.shell_focused;
309 Ok(())
310 }
311
312 fn show(&mut self, config: &TerminalConfig, cwd: &Path) -> Result<(), String> {
313 if self.sessions.is_empty() {
314 return self.new_shell(config, None, cwd);
315 }
316 self.visible = true;
317 self.shell_focused = true;
318 Ok(())
319 }
320
321 pub fn cycle(&mut self, delta: isize) {
323 let len = self.sessions.len();
324 if len > 1 {
325 let len = len as isize;
326 self.active = (self.active as isize + delta).rem_euclid(len) as usize;
327 }
328 }
329
330 pub fn select(&mut self, n: usize) -> Result<(), String> {
336 if n == 0 || n > self.sessions.len() {
337 return Err(format!(
338 "no shell {n} (there {})",
339 match self.sessions.len() {
340 0 => "are none".to_owned(),
341 1 => "is 1".to_owned(),
342 k => format!("are {k}"),
343 }
344 ));
345 }
346 self.active = n - 1;
347 self.visible = true;
348 Ok(())
349 }
350
351 pub fn close_active(&mut self) {
353 if self.active < self.sessions.len() {
354 let index = self.active;
355 self.sessions.remove(index);
356 self.after_removal(index);
357 }
358 }
359
360 pub fn toggle_maximized(&mut self) {
362 if self.visible {
363 self.maximized = !self.maximized;
364 }
365 }
366
367 #[must_use]
371 pub fn quit_confirmation(&self, policy: ConfirmPolicy, cancel: bool) -> Option<Confirm> {
372 if self.sessions.is_empty() {
373 return None;
374 }
375 let busy = match policy {
376 ConfirmPolicy::Never => return None,
377 ConfirmPolicy::Always | ConfirmPolicy::WhenBusy => self.busy_count(),
378 };
379 let prompt = match (policy, busy) {
380 (ConfirmPolicy::WhenBusy, 0) => return None,
381 (_, 0) => format!(" Quit and close {}? [y/N] ", shells(self.sessions.len())),
382 (_, 1) => " 1 shell is still running a command. Quit anyway? [y/N] ".to_owned(),
383 (_, n) => format!(" {n} shells are still running a command. Quit anyway? [y/N] "),
384 };
385 Some(Confirm::Quit { cancel, prompt })
386 }
387
388 fn busy_count(&self) -> usize {
389 let pids: Vec<Option<u32>> = self.sessions.iter().map(Session::pid).collect();
390 busy::busy(&pids).into_iter().filter(|b| *b).count()
391 }
392
393 pub fn request_close(&mut self, policy: ConfirmPolicy) {
395 let Some(session) = self.sessions.get(self.active) else {
396 return;
397 };
398 let name = format!("{}:{}", self.active + 1, session.label());
399 let prompt = match policy {
400 ConfirmPolicy::Never => None,
401 ConfirmPolicy::Always | ConfirmPolicy::WhenBusy => {
402 let running = busy::busy(&[session.pid()])
403 .first()
404 .copied()
405 .unwrap_or(true);
406 match (policy, running) {
407 (_, true) => Some(format!(
408 " Shell {name} is running a command. Close it? [y/N] "
409 )),
410 (ConfirmPolicy::Always, false) => Some(format!(" Close shell {name}? [y/N] ")),
411 _ => None,
412 }
413 }
414 };
415 match prompt {
416 Some(prompt) => self.pending = Some(Confirm::Close { prompt }),
417 None => self.close_active(),
418 }
419 }
420
421 pub fn ask(&mut self, confirm: Confirm) {
423 self.pending = Some(confirm);
424 }
425}
426
427fn shells(n: usize) -> String {
428 if n == 1 {
429 "1 shell".to_owned()
430 } else {
431 format!("{n} shells")
432 }
433}
434
435#[must_use]
441pub fn panel_rows(main_rows: u16, percent: u16, maximized: bool) -> u16 {
442 if maximized {
443 return main_rows;
444 }
445 let wanted = (u32::from(main_rows) * u32::from(percent) / 100) as u16;
446 let rows = wanted.max(MIN_PANEL_ROWS);
447 if main_rows.saturating_sub(rows) < MIN_LIST_ROWS {
448 main_rows
449 } else {
450 rows
451 }
452}
453
454fn initial_size(percent: u16, maximized: bool) -> (u16, u16) {
457 let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
458 let main_rows = rows.saturating_sub(1);
459 let panel = panel_rows(main_rows, percent, maximized);
460 (panel.saturating_sub(2), cols.saturating_sub(2))
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466
467 #[test]
468 fn confirm_policies_parse() {
469 assert_eq!(
470 ConfirmPolicy::parse("when_busy"),
471 Some(ConfirmPolicy::WhenBusy)
472 );
473 assert_eq!(
474 ConfirmPolicy::parse(" Always "),
475 Some(ConfirmPolicy::Always)
476 );
477 assert_eq!(ConfirmPolicy::parse("never"), Some(ConfirmPolicy::Never));
478 assert_eq!(ConfirmPolicy::parse("sometimes"), None);
479 }
480
481 #[test]
482 fn the_panel_takes_its_share_and_falls_back_to_the_whole_area() {
483 assert_eq!(panel_rows(40, 35, false), 14);
484 assert_eq!(panel_rows(40, 35, true), 40);
485 assert_eq!(panel_rows(20, 10, false), MIN_PANEL_ROWS);
487 assert_eq!(panel_rows(9, 35, false), 9);
489 }
490
491 #[test]
492 fn an_empty_panel_never_asks_and_never_opens_without_a_channel() {
493 let panel = TerminalPanel::default();
494 assert_eq!(panel.quit_confirmation(ConfirmPolicy::Always, false), None);
495
496 let dir = tempfile::tempdir().unwrap();
497 let config = crate::config::load(None).unwrap().terminal;
498 let mut panel = TerminalPanel::default();
499 let err = panel.toggle(&config, dir.path()).unwrap_err();
500 assert!(err.contains("not available"), "{err}");
501 assert!(!panel.is_visible(), "a failed open leaves the panel hidden");
502 }
503}