pub mod busy;
mod input;
pub mod keys;
pub mod profile;
pub mod session;
pub use input::{allow_quit, handle_key, run_command};
use std::path::Path;
use tokio::sync::mpsc;
use crate::config::TerminalConfig;
use crate::workers::WorkerMsg;
use session::Session;
pub const MIN_PANEL_ROWS: u16 = 5;
pub const MIN_LIST_ROWS: u16 = 5;
pub const TERMINAL_ACTIONS: &[&str] = &[
"toggle",
"focus",
"next_shell",
"prev_shell",
"scroll_up",
"scroll_down",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfirmPolicy {
WhenBusy,
Always,
Never,
}
pub const CONFIRM_REASON: &str = "must be when_busy, always or never";
impl ConfirmPolicy {
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"when_busy" => Some(Self::WhenBusy),
"always" => Some(Self::Always),
"never" => Some(Self::Never),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TermCommand {
Toggle,
New(Option<String>),
Close,
Select(usize),
Max,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Confirm {
Quit {
cancel: bool,
prompt: String,
},
Close {
prompt: String,
},
}
impl Confirm {
#[must_use]
pub fn prompt(&self) -> &str {
match self {
Self::Quit { prompt, .. } | Self::Close { prompt } => prompt,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyOutcome {
NotMine,
Handled,
Quit {
cancel: bool,
},
}
#[derive(Debug, Default)]
pub struct TerminalPanel {
sessions: Vec<Session>,
active: usize,
visible: bool,
shell_focused: bool,
maximized: bool,
next_id: u64,
pending: Option<Confirm>,
output_pending: bool,
notify: Option<mpsc::Sender<WorkerMsg>>,
}
impl TerminalPanel {
pub fn attach(&mut self, notify: mpsc::Sender<WorkerMsg>) {
self.notify = Some(notify);
}
#[must_use]
pub fn is_visible(&self) -> bool {
self.visible
}
#[must_use]
pub fn shell_focused(&self) -> bool {
self.visible && self.shell_focused
}
#[must_use]
pub fn is_maximized(&self) -> bool {
self.maximized
}
#[must_use]
pub fn sessions(&self) -> &[Session] {
&self.sessions
}
#[must_use]
pub fn active(&self) -> usize {
self.active
}
#[must_use]
pub fn pending(&self) -> Option<&Confirm> {
self.pending.as_ref()
}
pub fn resize_all(&mut self, size: (u16, u16)) {
for session in &mut self.sessions {
session.resize(size);
}
}
pub fn note_output(&mut self) {
if self.visible {
self.output_pending = true;
}
}
#[must_use]
pub fn output_pending(&self) -> bool {
self.output_pending
}
pub fn frame_drawn(&mut self) {
self.output_pending = false;
for session in &self.sessions {
session.output_drawn();
}
}
pub fn on_exit(&mut self, id: u64) -> bool {
let Some(index) = self.sessions.iter().position(|s| s.id() == id) else {
return false;
};
self.sessions.remove(index);
self.after_removal(index);
true
}
fn after_removal(&mut self, index: usize) {
if self.sessions.is_empty() {
self.visible = false;
self.shell_focused = false;
self.maximized = false;
self.active = 0;
if matches!(self.pending, Some(Confirm::Close { .. })) {
self.pending = None;
}
return;
}
if index < self.active || self.active >= self.sessions.len() {
self.active = self.active.saturating_sub(1);
}
}
pub fn new_shell(
&mut self,
config: &TerminalConfig,
profile_name: Option<&str>,
cwd: &Path,
) -> Result<(), String> {
let profile = profile::resolve(config, profile_name)?;
let notify = self
.notify
.clone()
.ok_or_else(|| "the terminal panel is not available".to_owned())?;
self.next_id += 1;
let size = initial_size(config.height, self.maximized);
let session = Session::spawn(
self.next_id,
&profile.name,
&profile.argv,
cwd,
size,
notify,
)?;
self.sessions.push(session);
self.active = self.sessions.len() - 1;
self.visible = true;
self.shell_focused = true;
Ok(())
}
pub fn toggle(&mut self, config: &TerminalConfig, cwd: &Path) -> Result<(), String> {
if self.visible {
self.visible = false;
self.shell_focused = false;
self.maximized = false;
return Ok(());
}
self.show(config, cwd)
}
pub fn switch_focus(&mut self, config: &TerminalConfig, cwd: &Path) -> Result<(), String> {
if !self.visible {
return self.show(config, cwd);
}
self.shell_focused = !self.shell_focused;
Ok(())
}
fn show(&mut self, config: &TerminalConfig, cwd: &Path) -> Result<(), String> {
if self.sessions.is_empty() {
return self.new_shell(config, None, cwd);
}
self.visible = true;
self.shell_focused = true;
Ok(())
}
pub fn cycle(&mut self, delta: isize) {
let len = self.sessions.len();
if len > 1 {
let len = len as isize;
self.active = (self.active as isize + delta).rem_euclid(len) as usize;
}
}
pub fn select(&mut self, n: usize) -> Result<(), String> {
if n == 0 || n > self.sessions.len() {
return Err(format!(
"no shell {n} (there {})",
match self.sessions.len() {
0 => "are none".to_owned(),
1 => "is 1".to_owned(),
k => format!("are {k}"),
}
));
}
self.active = n - 1;
self.visible = true;
Ok(())
}
pub fn close_active(&mut self) {
if self.active < self.sessions.len() {
let index = self.active;
self.sessions.remove(index);
self.after_removal(index);
}
}
pub fn toggle_maximized(&mut self) {
if self.visible {
self.maximized = !self.maximized;
}
}
#[must_use]
pub fn quit_confirmation(&self, policy: ConfirmPolicy, cancel: bool) -> Option<Confirm> {
if self.sessions.is_empty() {
return None;
}
let busy = match policy {
ConfirmPolicy::Never => return None,
ConfirmPolicy::Always | ConfirmPolicy::WhenBusy => self.busy_count(),
};
let prompt = match (policy, busy) {
(ConfirmPolicy::WhenBusy, 0) => return None,
(_, 0) => format!(" Quit and close {}? [y/N] ", shells(self.sessions.len())),
(_, 1) => " 1 shell is still running a command. Quit anyway? [y/N] ".to_owned(),
(_, n) => format!(" {n} shells are still running a command. Quit anyway? [y/N] "),
};
Some(Confirm::Quit { cancel, prompt })
}
fn busy_count(&self) -> usize {
let pids: Vec<Option<u32>> = self.sessions.iter().map(Session::pid).collect();
busy::busy(&pids).into_iter().filter(|b| *b).count()
}
pub fn request_close(&mut self, policy: ConfirmPolicy) {
let Some(session) = self.sessions.get(self.active) else {
return;
};
let name = format!("{}:{}", self.active + 1, session.label());
let prompt = match policy {
ConfirmPolicy::Never => None,
ConfirmPolicy::Always | ConfirmPolicy::WhenBusy => {
let running = busy::busy(&[session.pid()])
.first()
.copied()
.unwrap_or(true);
match (policy, running) {
(_, true) => Some(format!(
" Shell {name} is running a command. Close it? [y/N] "
)),
(ConfirmPolicy::Always, false) => Some(format!(" Close shell {name}? [y/N] ")),
_ => None,
}
}
};
match prompt {
Some(prompt) => self.pending = Some(Confirm::Close { prompt }),
None => self.close_active(),
}
}
pub fn ask(&mut self, confirm: Confirm) {
self.pending = Some(confirm);
}
}
fn shells(n: usize) -> String {
if n == 1 {
"1 shell".to_owned()
} else {
format!("{n} shells")
}
}
#[must_use]
pub fn panel_rows(main_rows: u16, percent: u16, maximized: bool) -> u16 {
if maximized {
return main_rows;
}
let wanted = (u32::from(main_rows) * u32::from(percent) / 100) as u16;
let rows = wanted.max(MIN_PANEL_ROWS);
if main_rows.saturating_sub(rows) < MIN_LIST_ROWS {
main_rows
} else {
rows
}
}
fn initial_size(percent: u16, maximized: bool) -> (u16, u16) {
let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
let main_rows = rows.saturating_sub(1);
let panel = panel_rows(main_rows, percent, maximized);
(panel.saturating_sub(2), cols.saturating_sub(2))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn confirm_policies_parse() {
assert_eq!(
ConfirmPolicy::parse("when_busy"),
Some(ConfirmPolicy::WhenBusy)
);
assert_eq!(
ConfirmPolicy::parse(" Always "),
Some(ConfirmPolicy::Always)
);
assert_eq!(ConfirmPolicy::parse("never"), Some(ConfirmPolicy::Never));
assert_eq!(ConfirmPolicy::parse("sometimes"), None);
}
#[test]
fn the_panel_takes_its_share_and_falls_back_to_the_whole_area() {
assert_eq!(panel_rows(40, 35, false), 14);
assert_eq!(panel_rows(40, 35, true), 40);
assert_eq!(panel_rows(20, 10, false), MIN_PANEL_ROWS);
assert_eq!(panel_rows(9, 35, false), 9);
}
#[test]
fn an_empty_panel_never_asks_and_never_opens_without_a_channel() {
let panel = TerminalPanel::default();
assert_eq!(panel.quit_confirmation(ConfirmPolicy::Always, false), None);
let dir = tempfile::tempdir().unwrap();
let config = crate::config::load(None).unwrap().terminal;
let mut panel = TerminalPanel::default();
let err = panel.toggle(&config, dir.path()).unwrap_err();
assert!(err.contains("not available"), "{err}");
assert!(!panel.is_visible(), "a failed open leaves the panel hidden");
}
}