use std::collections::HashMap;
use std::io;
use std::sync::{Arc, Mutex, MutexGuard, PoisonError, mpsc};
use std::thread;
use std::time::Duration;
use ratatui::DefaultTerminal;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, Paragraph};
use runandlog_core::{Canceller, ExecOutcome};
use crate::live::LiveOutput;
use crate::session::Session;
const TICK: Duration = Duration::from_millis(100);
const SPINNER: [char; 4] = ['|', '/', '-', '\\'];
const PAGE_LINES: usize = 10;
const LIVE_TAIL_LINES: usize = 3;
const LIVE_LIMIT: usize = 16 * 1024;
pub fn run(mut session: Session) -> io::Result<()> {
let (password_tx, password_rx) = mpsc::channel();
let password_queue = Arc::new(Mutex::new(PasswordQueue {
accepting: false,
requests: password_tx,
}));
#[cfg(unix)]
let _askpass = start_askpass(&mut session, Arc::clone(&password_queue));
let terminal = ratatui::init();
let result = App::new(session, password_rx, password_queue).run(terminal);
ratatui::restore();
result
}
struct PasswordRequest {
prompt: String,
reply: mpsc::Sender<Option<String>>,
}
struct PasswordPrompt {
request: PasswordRequest,
typed: String,
}
impl PasswordPrompt {
fn answer(self, answer: Option<String>) {
let _ = self.request.reply.send(answer);
}
}
struct PasswordQueue {
accepting: bool,
requests: mpsc::Sender<PasswordRequest>,
}
impl PasswordQueue {
fn lock(queue: &Mutex<PasswordQueue>) -> MutexGuard<'_, PasswordQueue> {
queue.lock().unwrap_or_else(PoisonError::into_inner)
}
fn submit(
queue: &Mutex<PasswordQueue>,
request: PasswordRequest,
gone: &dyn Fn() -> bool,
) -> Option<()> {
let queue = Self::lock(queue);
if !queue.accepting || gone() {
return None;
}
queue.requests.send(request).ok()
}
}
#[cfg(unix)]
fn start_askpass(
session: &mut Session,
queue: Arc<Mutex<PasswordQueue>>,
) -> Option<crate::askpass::Askpass> {
let askpass = crate::askpass::Askpass::start(Box::new(move |prompt, gone| {
let (reply, answer) = mpsc::channel();
PasswordQueue::submit(
&queue,
PasswordRequest {
prompt: prompt.to_string(),
reply,
},
gone,
)?;
crate::askpass::wait_for_answer(&answer, gone)
}))
.ok()?;
session.set_run_hook(askpass.hook());
Some(askpass)
}
fn decline_prompts(shown: &mut Option<PasswordPrompt>, queued: &mpsc::Receiver<PasswordRequest>) {
if let Some(prompt) = shown.take() {
prompt.answer(None);
}
while let Ok(request) = queued.try_recv() {
let _ = request.reply.send(None);
}
}
#[derive(Debug, PartialEq, Eq)]
enum PromptKey {
Type(char),
Erase,
Send,
Decline,
Ignore,
}
fn prompt_key(key: KeyEvent) -> PromptKey {
match key.code {
KeyCode::Enter => PromptKey::Send,
KeyCode::Esc => PromptKey::Decline,
KeyCode::Backspace => PromptKey::Erase,
KeyCode::Char(c)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
PromptKey::Type(c)
}
_ => PromptKey::Ignore,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Batch {
Continue,
Stop,
}
fn is_quit_key(key: KeyEvent) -> bool {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => true,
KeyCode::Char('c') => key.modifiers.contains(KeyModifiers::CONTROL),
_ => false,
}
}
fn is_cancel_key(key: KeyEvent) -> bool {
key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)
}
fn run_label(has_result: bool) -> &'static str {
if has_result { "Re-run" } else { "Run " }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CellState {
Waiting,
Running,
Done,
Failed,
}
fn cell_state(index: usize, running: Option<usize>, finished: Option<bool>) -> CellState {
if running == Some(index) {
return CellState::Running;
}
match finished {
Some(true) => CellState::Done,
Some(false) => CellState::Failed,
None => CellState::Waiting,
}
}
fn marker_color(state: CellState) -> Color {
match state {
CellState::Waiting => Color::Green,
CellState::Running => Color::Yellow,
CellState::Done => Color::Blue,
CellState::Failed => Color::Red,
}
}
fn command_style(state: CellState) -> Style {
match state {
CellState::Done | CellState::Failed => Style::default().fg(Color::DarkGray),
_ => Style::default(),
}
}
struct Rendered {
lines: Vec<Line<'static>>,
spans: Vec<(usize, usize)>,
}
struct App {
session: Session,
selected: usize,
scroll: usize,
viewport: usize,
status: String,
running: Option<(usize, usize)>,
live: LiveOutput,
password_requests: mpsc::Receiver<PasswordRequest>,
password_queue: Arc<Mutex<PasswordQueue>>,
password: Option<PasswordPrompt>,
finished: HashMap<usize, bool>,
quit: bool,
}
impl App {
fn new(
session: Session,
password_requests: mpsc::Receiver<PasswordRequest>,
password_queue: Arc<Mutex<PasswordQueue>>,
) -> App {
let status = if session.is_empty() {
"No runnable cells. Press q to quit.".to_string()
} else {
"Enter/r: run a: run all j/k: move R: reload q: quit".to_string()
};
App {
session,
selected: 0,
scroll: 0,
viewport: 1,
status,
running: None,
live: LiveOutput::new(LIVE_LIMIT),
password_requests,
password_queue,
password: None,
finished: HashMap::new(),
quit: false,
}
}
fn run(mut self, mut terminal: DefaultTerminal) -> io::Result<()> {
while !self.quit {
self.redraw(&mut terminal)?;
self.handle_events(&mut terminal)?;
}
Ok(())
}
fn redraw(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
let rendered = self.render_lines();
terminal.draw(|frame| {
let areas = Layout::vertical([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1),
])
.split(frame.area());
let header = Line::from(vec![
Span::styled("File: ", Style::default().fg(Color::DarkGray)),
Span::styled(
self.session.path().display().to_string(),
Style::default().add_modifier(Modifier::BOLD),
),
]);
frame.render_widget(Paragraph::new(header), areas[0]);
self.viewport = (areas[1].height.saturating_sub(2) as usize).max(1);
self.adjust_scroll(&rendered);
let body = Paragraph::new(Text::from(rendered.lines.clone()))
.block(Block::default().borders(Borders::TOP | Borders::BOTTOM))
.scroll((self.scroll as u16, 0));
frame.render_widget(body, areas[1]);
let status = match (&self.password, self.running) {
(Some(prompt), _) => Line::from(vec![
Span::styled(
format!("{} ", prompt.request.prompt),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw("*".repeat(prompt.typed.chars().count())),
Span::styled(
" (Enter: send Esc: decline Ctrl-C: stop)",
Style::default().fg(Color::DarkGray),
),
]),
(None, Some((index, phase))) => Line::from(Span::styled(
format!(
"{} running cell {}",
SPINNER[phase % SPINNER.len()],
index + 1
),
Style::default().fg(Color::DarkGray),
)),
(None, None) => Line::from(Span::styled(
self.status.clone(),
Style::default().fg(Color::DarkGray),
)),
};
frame.render_widget(Paragraph::new(status), areas[2]);
})?;
Ok(())
}
fn render_lines(&self) -> Rendered {
let mut lines = Vec::new();
let mut spans = Vec::new();
let doc = self.session.doc();
let running = self.running.map(|(index, _)| index);
for cell in &doc.cells {
let start = lines.len();
let selected = cell.index == self.selected;
let state = cell_state(cell.index, running, self.finished.get(&cell.index).copied());
let color = marker_color(state);
let marker_style = if selected {
Style::default()
.fg(Color::Black)
.bg(color)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(color)
};
lines.push(Line::from(vec![
Span::styled(
format!(
" [{}] > {} ",
cell.display_number(),
run_label(doc.result_text(cell).is_some())
),
marker_style,
),
Span::styled(
match &cell.out_file {
Some(path) => format!(" -> {path}"),
None => String::new(),
},
Style::default().fg(Color::DarkGray),
),
]));
for command in cell.command.lines() {
lines.push(Line::from(vec![
Span::styled(" > ", Style::default().fg(Color::DarkGray)),
Span::styled(command.to_string(), command_style(state)),
]));
}
if state == CellState::Running {
lines.extend(self.live_lines());
} else if let Some(result) = doc.result_text(cell) {
lines.push(Line::from(""));
for line in result.lines() {
lines.push(Line::from(Span::styled(
format!(" {line}"),
Style::default().fg(Color::Gray),
)));
}
}
lines.push(Line::from(""));
spans.push((start, lines.len()));
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
" No shell / sh / bash / zsh code block found.",
Style::default().fg(Color::DarkGray),
)));
}
Rendered { lines, spans }
}
fn live_lines(&self) -> Vec<Line<'static>> {
let mut lines = vec![Line::from("")];
let tail = self.live.tail_lines(LIVE_TAIL_LINES);
for index in 0..LIVE_TAIL_LINES {
let line = match tail.get(index) {
Some(line) => format!(" {line}"),
None if index == 0 && tail.is_empty() => " (no output yet)".to_string(),
None => String::new(),
};
lines.push(Line::from(Span::styled(
line,
Style::default().fg(Color::DarkGray),
)));
}
lines
}
fn adjust_scroll(&mut self, rendered: &Rendered) {
if let Some(&(start, end)) = rendered.spans.get(self.selected) {
if start < self.scroll {
self.scroll = start;
} else if end > self.scroll + self.viewport {
self.scroll = end.saturating_sub(self.viewport).min(start);
}
}
self.scroll = self.scroll.min(rendered.lines.len().saturating_sub(1));
}
fn handle_events(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
while let Ok(request) = self.password_requests.try_recv() {
let _ = request.reply.send(None);
}
if !event::poll(TICK)? {
return Ok(());
}
if let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
{
self.handle_key(key, terminal)?;
}
Ok(())
}
fn handle_key(&mut self, key: KeyEvent, terminal: &mut DefaultTerminal) -> io::Result<()> {
if is_quit_key(key) {
self.quit = true;
return Ok(());
}
match key.code {
KeyCode::Char('j') | KeyCode::Down => self.select(1),
KeyCode::Char('k') | KeyCode::Up => self.select(-1),
KeyCode::Char('g') | KeyCode::Home => {
self.selected = 0;
self.scroll = 0;
}
KeyCode::Char('G') | KeyCode::End => {
self.selected = self.session.len().saturating_sub(1);
}
KeyCode::PageDown => self.scroll = self.scroll.saturating_add(PAGE_LINES),
KeyCode::PageUp => self.scroll = self.scroll.saturating_sub(PAGE_LINES),
KeyCode::Char('R') => self.reload(),
KeyCode::Enter | KeyCode::Char('r') => {
if !self.session.is_empty() {
self.execute(self.selected, terminal)?;
}
}
KeyCode::Char('a') => {
for index in 0..self.session.len() {
if self.quit {
break;
}
self.selected = index;
if self.execute(index, terminal)? == Batch::Stop {
break;
}
}
}
_ => {}
}
Ok(())
}
fn select(&mut self, delta: isize) {
if self.session.is_empty() {
return;
}
let last = self.session.len() - 1;
self.selected = self.selected.saturating_add_signed(delta).min(last);
}
fn reload(&mut self) {
match self.session.reload() {
Ok(()) => {
self.selected = self.selected.min(self.session.len().saturating_sub(1));
self.finished.clear();
self.status = "Reloaded.".to_string();
}
Err(error) => self.status = format!("Reload failed: {error}"),
}
}
fn execute(&mut self, index: usize, terminal: &mut DefaultTerminal) -> io::Result<Batch> {
let command = self.session.command_of(index);
PasswordQueue::lock(&self.password_queue).accepting = true;
let run = self.session.start_run();
let canceller = Canceller::new();
let worker_canceller = canceller.clone();
let (tx, rx) = mpsc::channel();
let (output_tx, output_rx) = mpsc::channel();
thread::spawn(move || {
let outcome =
runandlog_core::run_streaming(&command, &run.options, &worker_canceller, |chunk| {
let _ = output_tx.send(chunk.to_string());
});
drop(run);
let _ = tx.send(outcome);
});
self.live.clear();
self.running = Some((index, 0));
let outcome = self.wait_for(index, rx, output_rx, terminal, &canceller);
{
let mut queue = PasswordQueue::lock(&self.password_queue);
queue.accepting = false;
decline_prompts(&mut self.password, &self.password_requests);
}
self.running = None;
self.live.clear();
match outcome {
Ok(Ok(outcome)) => match self.session.apply_outcome(index, &outcome) {
Ok(()) => {
self.finished.insert(index, outcome.is_success());
self.status = format!("Cell {} done ({})", index + 1, outcome.status_text());
}
Err(error) => {
self.status = format!("Writing the result failed: {error}");
self.reload_after_conflict();
return Ok(Batch::Stop);
}
},
Ok(Err(error)) => {
self.finished.insert(index, false);
self.status = format!("The run failed: {error}");
}
Err(error) => return Err(error),
}
Ok(Batch::Continue)
}
fn reload_after_conflict(&mut self) {
if self.session.reload().is_ok() {
self.selected = self.selected.min(self.session.len().saturating_sub(1));
self.finished.clear();
}
}
fn drain_events_while_running(&mut self, canceller: &Canceller) -> io::Result<()> {
while event::poll(Duration::ZERO)? {
if let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
{
if is_cancel_key(key) {
canceller.cancel();
self.quit = true;
} else if self.password.is_some() {
self.type_into_prompt(key);
} else if is_quit_key(key) {
self.quit = true;
}
}
}
Ok(())
}
fn take_password_request(&mut self) {
if self.password.is_none()
&& let Ok(request) = self.password_requests.try_recv()
{
self.password = Some(PasswordPrompt {
request,
typed: String::new(),
});
}
}
fn type_into_prompt(&mut self, key: KeyEvent) {
let Some(prompt) = self.password.as_mut() else {
return;
};
match prompt_key(key) {
PromptKey::Type(c) => prompt.typed.push(c),
PromptKey::Erase => {
prompt.typed.pop();
}
PromptKey::Send => {
if let Some(prompt) = self.password.take() {
let typed = prompt.typed.clone();
prompt.answer(Some(typed));
}
}
PromptKey::Decline => {
if let Some(prompt) = self.password.take() {
prompt.answer(None);
}
}
PromptKey::Ignore => {}
}
}
fn collect_output(&mut self, output_rx: &mpsc::Receiver<String>) {
while let Ok(chunk) = output_rx.try_recv() {
self.live.push(&chunk);
}
}
fn wait_for(
&mut self,
index: usize,
rx: mpsc::Receiver<io::Result<ExecOutcome>>,
output_rx: mpsc::Receiver<String>,
terminal: &mut DefaultTerminal,
canceller: &Canceller,
) -> io::Result<io::Result<ExecOutcome>> {
let mut phase = 0;
loop {
match rx.recv_timeout(TICK) {
Ok(result) => {
self.drain_events_while_running(canceller)?;
return Ok(result);
}
Err(mpsc::RecvTimeoutError::Timeout) => {
phase += 1;
self.running = Some((index, phase));
self.collect_output(&output_rx);
self.take_password_request();
self.redraw(terminal)?;
self.drain_events_while_running(canceller)?;
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
return Ok(Err(io::Error::other("the worker thread died unexpectedly")));
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, modifiers)
}
#[test]
fn requests_are_taken_only_while_a_run_is() {
let (requests_tx, requests_rx) = mpsc::channel();
let queue = Mutex::new(PasswordQueue {
accepting: false,
requests: requests_tx,
});
let ask = || {
let (reply, _answer) = mpsc::channel();
PasswordRequest {
prompt: "Password:".to_string(),
reply,
}
};
let alive = || false;
assert_eq!(PasswordQueue::submit(&queue, ask(), &alive), None);
assert!(requests_rx.try_recv().is_err());
PasswordQueue::lock(&queue).accepting = true;
assert_eq!(PasswordQueue::submit(&queue, ask(), &alive), Some(()));
assert!(requests_rx.try_recv().is_ok());
assert_eq!(PasswordQueue::submit(&queue, ask(), &|| true), None);
assert!(requests_rx.try_recv().is_err());
PasswordQueue::lock(&queue).accepting = false;
assert_eq!(PasswordQueue::submit(&queue, ask(), &alive), None);
assert!(requests_rx.try_recv().is_err());
}
#[test]
fn the_end_of_a_run_declines_the_prompt_shown_and_the_ones_still_queued() {
let (requests_tx, requests_rx) = mpsc::channel();
let ask = |prompt: &str| {
let (reply, answer) = mpsc::channel();
(
PasswordRequest {
prompt: prompt.to_string(),
reply,
},
answer,
)
};
let (shown, shown_answer) = ask("shown");
let mut shown = Some(PasswordPrompt {
request: shown,
typed: "half-typ".to_string(),
});
let (queued, queued_answer) = ask("queued");
requests_tx.send(queued).unwrap();
let (later, later_answer) = ask("later");
requests_tx.send(later).unwrap();
decline_prompts(&mut shown, &requests_rx);
assert!(shown.is_none());
assert_eq!(shown_answer.try_recv(), Ok(None));
assert_eq!(queued_answer.try_recv(), Ok(None));
assert_eq!(later_answer.try_recv(), Ok(None));
assert!(requests_rx.try_recv().is_err());
}
#[test]
fn recognises_the_documented_quit_keys() {
assert!(is_quit_key(key(KeyCode::Char('q'), KeyModifiers::NONE)));
assert!(is_quit_key(key(KeyCode::Esc, KeyModifiers::NONE)));
assert!(is_quit_key(key(KeyCode::Char('c'), KeyModifiers::CONTROL)));
}
#[test]
fn a_cell_that_already_ran_says_so() {
assert_eq!(run_label(true).trim(), "Re-run");
assert_eq!(run_label(false).trim(), "Run");
assert_eq!(run_label(true).len(), run_label(false).len());
}
#[test]
fn a_cell_that_has_not_run_in_this_session_looks_untouched() {
assert_eq!(cell_state(0, None, None), CellState::Waiting);
}
#[test]
fn the_cell_being_run_is_told_apart_from_the_ones_already_run() {
assert_eq!(cell_state(1, Some(1), None), CellState::Running);
assert_eq!(cell_state(1, Some(1), Some(true)), CellState::Running);
assert_eq!(cell_state(0, Some(1), Some(true)), CellState::Done);
assert_eq!(cell_state(0, Some(1), Some(false)), CellState::Failed);
}
#[test]
fn every_state_has_a_colour_of_its_own() {
let colors = [
marker_color(CellState::Waiting),
marker_color(CellState::Running),
marker_color(CellState::Done),
marker_color(CellState::Failed),
];
for (index, color) in colors.iter().enumerate() {
assert!(
!colors[index + 1..].contains(color),
"two states share {color:?}"
);
}
}
#[test]
fn a_finished_cell_is_dimmed_and_the_rest_are_not() {
assert_eq!(command_style(CellState::Waiting), Style::default());
assert_eq!(command_style(CellState::Running), Style::default());
assert_ne!(command_style(CellState::Done), Style::default());
assert_ne!(command_style(CellState::Failed), Style::default());
}
#[test]
fn keys_go_into_the_password_prompt() {
assert_eq!(
prompt_key(key(KeyCode::Char('q'), KeyModifiers::NONE)),
PromptKey::Type('q')
);
assert_eq!(
prompt_key(key(KeyCode::Char('Q'), KeyModifiers::SHIFT)),
PromptKey::Type('Q')
);
assert_eq!(
prompt_key(key(KeyCode::Enter, KeyModifiers::NONE)),
PromptKey::Send
);
assert_eq!(
prompt_key(key(KeyCode::Esc, KeyModifiers::NONE)),
PromptKey::Decline
);
assert_eq!(
prompt_key(key(KeyCode::Backspace, KeyModifiers::NONE)),
PromptKey::Erase
);
assert_eq!(
prompt_key(key(KeyCode::Char('u'), KeyModifiers::CONTROL)),
PromptKey::Ignore
);
}
#[test]
fn a_plain_c_is_not_a_quit_key() {
assert!(!is_quit_key(key(KeyCode::Char('c'), KeyModifiers::NONE)));
assert!(!is_quit_key(key(KeyCode::Char('r'), KeyModifiers::NONE)));
assert!(!is_quit_key(key(KeyCode::Char('a'), KeyModifiers::NONE)));
}
}