diff options
Diffstat (limited to 'src/shell/history')
-rw-r--r-- | src/shell/history/entry.rs | 429 | ||||
-rw-r--r-- | src/shell/history/mod.rs | 208 | ||||
-rw-r--r-- | src/shell/history/pty.rs | 196 |
3 files changed, 833 insertions, 0 deletions
diff --git a/src/shell/history/entry.rs b/src/shell/history/entry.rs new file mode 100644 index 0000000..0491bf7 --- /dev/null +++ b/src/shell/history/entry.rs @@ -0,0 +1,429 @@ +use crate::shell::prelude::*; + +pub struct Entry { + cmdline: String, + env: Env, + pty: super::pty::Pty, + fullscreen: Option<bool>, + start_instant: std::time::Instant, + start_time: time::OffsetDateTime, + state: State, +} + +impl Entry { + pub fn new( + cmdline: String, + env: Env, + size: (u16, u16), + event_w: crate::shell::event::Writer, + ) -> Result<Self> { + let start_instant = std::time::Instant::now(); + let start_time = time::OffsetDateTime::now_utc(); + + let (pty, pts) = super::pty::Pty::new(size, event_w.clone()).unwrap(); + let (child, fh) = Self::spawn_command(&cmdline, &env, &pts)?; + tokio::spawn(Self::task(child, fh, env.idx(), event_w)); + Ok(Self { + cmdline, + env, + pty, + fullscreen: None, + start_instant, + start_time, + state: State::Running((0, 0)), + }) + } + + pub fn render( + &self, + out: &mut impl textmode::Textmode, + entry_count: usize, + vt: &mut super::pty::Vt, + focused: bool, + scrolling: bool, + offset: time::UtcOffset, + ) { + let idx = self.env.idx(); + let size = out.screen().size(); + let time = self.state.exit_info().map_or_else( + || { + format!( + "[{}]", + crate::format::time(self.start_time.to_offset(offset)) + ) + }, + |info| { + format!( + "({}) [{}]", + crate::format::duration( + info.instant - self.start_instant + ), + crate::format::time(self.start_time.to_offset(offset)), + ) + }, + ); + + if vt.bell(focused) { + out.write(b"\x07"); + } + + Self::set_bgcolor(out, idx, focused); + out.set_fgcolor(textmode::color::YELLOW); + let entry_count_width = format!("{}", entry_count + 1).len(); + let idx_str = format!("{}", idx + 1); + out.write_str(&" ".repeat(entry_count_width - idx_str.len())); + out.write_str(&idx_str); + out.write_str(" "); + out.reset_attributes(); + + Self::set_bgcolor(out, idx, focused); + if let Some(info) = self.state.exit_info() { + if info.status.signal().is_some() { + out.set_fgcolor(textmode::color::MAGENTA); + } else if info.status.success() { + out.set_fgcolor(textmode::color::DARKGREY); + } else { + out.set_fgcolor(textmode::color::RED); + } + out.write_str(&crate::format::exit_status(info.status)); + } else { + out.write_str(" "); + } + out.reset_attributes(); + + if vt.is_bell() { + out.set_bgcolor(textmode::Color::Rgb(64, 16, 16)); + } else { + Self::set_bgcolor(out, idx, focused); + } + out.write_str("$ "); + Self::set_bgcolor(out, idx, focused); + let start = usize::from(out.screen().cursor_position().1); + let end = usize::from(size.1) - time.len() - 2; + let max_len = end - start; + let cmd = if self.cmd().len() > max_len { + &self.cmd()[..(max_len - 4)] + } else { + self.cmd() + }; + if let State::Running(span) = self.state { + let span = (span.0.min(cmd.len()), span.1.min(cmd.len())); + if !cmd[..span.0].is_empty() { + out.write_str(&cmd[..span.0]); + } + if !cmd[span.0..span.1].is_empty() { + out.set_bgcolor(textmode::Color::Rgb(16, 64, 16)); + out.write_str(&cmd[span.0..span.1]); + Self::set_bgcolor(out, idx, focused); + } + if !cmd[span.1..].is_empty() { + out.write_str(&cmd[span.1..]); + } + } else { + out.write_str(cmd); + } + if self.cmd().len() > max_len { + if let State::Running(span) = self.state { + if span.0 < cmd.len() && span.1 > cmd.len() { + out.set_bgcolor(textmode::Color::Rgb(16, 64, 16)); + } + } + out.write_str(" "); + if let State::Running(span) = self.state { + if span.1 > cmd.len() { + out.set_bgcolor(textmode::Color::Rgb(16, 64, 16)); + } + } + out.set_fgcolor(textmode::color::BLUE); + out.write_str("..."); + } + out.reset_attributes(); + + Self::set_bgcolor(out, idx, focused); + let cur_pos = out.screen().cursor_position(); + out.write_str(&" ".repeat( + usize::from(size.1) - time.len() - 1 - usize::from(cur_pos.1), + )); + out.write_str(&time); + out.write_str(" "); + out.reset_attributes(); + + if vt.binary() { + let msg = "This appears to be binary data. Fullscreen this entry to view anyway."; + let len: u16 = msg.len().try_into().unwrap(); + out.move_to( + out.screen().cursor_position().0 + 1, + (size.1 - len) / 2, + ); + out.set_fgcolor(textmode::color::RED); + out.write_str(msg); + out.hide_cursor(true); + } else { + let last_row = + vt.output_lines(focused && !scrolling, self.state.running()); + let mut max_lines = self.max_lines(entry_count); + if last_row > max_lines { + out.write(b"\r\n"); + out.set_fgcolor(textmode::color::BLUE); + out.write_str("..."); + out.reset_attributes(); + max_lines -= 1; + } + let mut out_row = out.screen().cursor_position().0 + 1; + let screen = vt.screen(); + let pos = screen.cursor_position(); + let mut wrapped = false; + let mut cursor_found = None; + for (idx, row) in screen + .rows_formatted(0, size.1) + .enumerate() + .take(last_row) + .skip(last_row.saturating_sub(max_lines)) + { + let idx: u16 = idx.try_into().unwrap(); + out.reset_attributes(); + if !wrapped { + out.move_to(out_row, 0); + } + out.write(&row); + wrapped = screen.row_wrapped(idx); + if pos.0 == idx { + cursor_found = Some(out_row); + } + out_row += 1; + } + if focused && !scrolling { + if let Some(row) = cursor_found { + out.hide_cursor(screen.hide_cursor()); + out.move_to(row, pos.1); + } else { + out.hide_cursor(true); + } + } + } + + out.reset_attributes(); + } + + pub fn render_fullscreen(&self, out: &mut impl textmode::Textmode) { + self.pty.with_vt_mut(|vt| { + out.write(&vt.screen().state_formatted()); + if vt.bell(true) { + out.write(b"\x07"); + } + out.reset_attributes(); + }); + } + + pub fn input(&self, bytes: Vec<u8>) { + self.pty.input(bytes); + } + + pub fn resize(&self, size: (u16, u16)) { + self.pty.resize(size); + } + + pub fn cmd(&self) -> &str { + &self.cmdline + } + + pub fn start_time(&self) -> time::OffsetDateTime { + self.start_time + } + + pub fn toggle_fullscreen(&mut self) { + if let Some(fullscreen) = self.fullscreen { + self.fullscreen = Some(!fullscreen); + } else { + self.fullscreen = Some(!self.pty.fullscreen()); + } + } + + pub fn set_fullscreen(&mut self, fullscreen: bool) { + self.fullscreen = Some(fullscreen); + } + + pub fn running(&self) -> bool { + self.state.running() + } + + pub fn exited(&mut self, exit_info: ExitInfo) { + self.state = State::Exited(exit_info); + } + + pub fn lines(&self, entry_count: usize, focused: bool) -> usize { + let running = self.running(); + 1 + std::cmp::min( + self.pty.with_vt(|vt| vt.output_lines(focused, running)), + self.max_lines(entry_count), + ) + } + + pub fn should_fullscreen(&self) -> bool { + self.fullscreen.unwrap_or_else(|| self.pty.fullscreen()) + } + + pub fn lock_vt(&self) -> std::sync::MutexGuard<super::pty::Vt> { + self.pty.lock_vt() + } + + pub fn set_span(&mut self, new_span: (usize, usize)) { + if let State::Running(ref mut span) = self.state { + *span = new_span; + } + } + + fn max_lines(&self, entry_count: usize) -> usize { + if self.env.idx() == entry_count - 1 { + 15 + } else { + 5 + } + } + + fn set_bgcolor( + out: &mut impl textmode::Textmode, + idx: usize, + focus: bool, + ) { + if focus { + out.set_bgcolor(textmode::Color::Rgb(0x56, 0x1b, 0x8b)); + } else if idx % 2 == 0 { + out.set_bgcolor(textmode::Color::Rgb(0x24, 0x21, 0x00)); + } else { + out.set_bgcolor(textmode::Color::Rgb(0x20, 0x20, 0x20)); + } + } + + fn spawn_command( + cmdline: &str, + env: &Env, + pts: &pty_process::Pts, + ) -> Result<(tokio::process::Child, std::fs::File)> { + let mut cmd = pty_process::Command::new(crate::info::current_exe()?); + cmd.args(&["-c", cmdline, "--status-fd", "3"]); + env.apply(&mut cmd); + let (from_r, from_w) = + nix::unistd::pipe2(nix::fcntl::OFlag::O_CLOEXEC)?; + // Safety: from_r was just opened above and is not used anywhere else + let fh = unsafe { std::fs::File::from_raw_fd(from_r) }; + // Safety: dup2 is an async-signal-safe function + unsafe { + cmd.pre_exec(move || { + nix::unistd::dup2(from_w, 3)?; + Ok(()) + }); + } + let child = cmd.spawn(pts)?; + nix::unistd::close(from_w)?; + Ok((child, fh)) + } + + async fn task( + mut child: tokio::process::Child, + fh: std::fs::File, + idx: usize, + event_w: crate::shell::event::Writer, + ) { + enum Res { + Read(crate::runner::Event), + Exit(std::io::Result<std::process::ExitStatus>), + } + + let (read_w, read_r) = tokio::sync::mpsc::unbounded_channel(); + tokio::task::spawn_blocking(move || loop { + let event = bincode::deserialize_from(&fh); + match event { + Ok(event) => { + read_w.send(event).unwrap(); + } + Err(e) => { + match &*e { + bincode::ErrorKind::Io(io_e) => { + assert!( + io_e.kind() + == std::io::ErrorKind::UnexpectedEof + ); + } + e => { + panic!("{}", e); + } + } + break; + } + } + }); + + let mut stream: futures_util::stream::SelectAll<_> = [ + tokio_stream::wrappers::UnboundedReceiverStream::new(read_r) + .map(Res::Read) + .boxed(), + futures_util::stream::once(child.wait()) + .map(Res::Exit) + .boxed(), + ] + .into_iter() + .collect(); + let mut exit_status = None; + let mut new_env = None; + while let Some(res) = stream.next().await { + match res { + Res::Read(event) => match event { + crate::runner::Event::RunPipeline(new_span) => { + // we could just update the span in place here, but we + // do this as an event so that we can also trigger a + // refresh + event_w.send(Event::ChildRunPipeline(idx, new_span)); + } + crate::runner::Event::Suspend => { + event_w.send(Event::ChildSuspend(idx)); + } + crate::runner::Event::Exit(env) => { + new_env = Some(env); + } + }, + Res::Exit(status) => { + exit_status = Some(status.unwrap()); + } + } + } + event_w.send(Event::ChildExit( + idx, + ExitInfo::new(exit_status.unwrap()), + new_env, + )); + } +} + +enum State { + Running((usize, usize)), + Exited(ExitInfo), +} + +impl State { + fn exit_info(&self) -> Option<&ExitInfo> { + match self { + Self::Running(_) => None, + Self::Exited(exit_info) => Some(exit_info), + } + } + + fn running(&self) -> bool { + self.exit_info().is_none() + } +} + +#[derive(Debug)] +pub struct ExitInfo { + status: std::process::ExitStatus, + instant: std::time::Instant, +} + +impl ExitInfo { + fn new(status: std::process::ExitStatus) -> Self { + Self { + status, + instant: std::time::Instant::now(), + } + } +} diff --git a/src/shell/history/mod.rs b/src/shell/history/mod.rs new file mode 100644 index 0000000..91149c1 --- /dev/null +++ b/src/shell/history/mod.rs @@ -0,0 +1,208 @@ +use crate::shell::prelude::*; + +mod entry; +pub use entry::{Entry, ExitInfo}; +mod pty; + +pub struct History { + size: (u16, u16), + entries: Vec<Entry>, + scroll_pos: usize, +} + +impl History { + pub fn new() -> Self { + Self { + size: (24, 80), + entries: vec![], + scroll_pos: 0, + } + } + + pub fn render( + &self, + out: &mut impl textmode::Textmode, + repl_lines: usize, + focus: Option<usize>, + scrolling: bool, + offset: time::UtcOffset, + ) { + let mut cursor = None; + for (idx, used_lines, mut vt) in + self.visible(repl_lines, focus, scrolling).rev() + { + let focused = focus.map_or(false, |focus| idx == focus); + out.move_to( + (usize::from(self.size.0) - used_lines).try_into().unwrap(), + 0, + ); + self.entries[idx].render( + out, + self.entry_count(), + &mut *vt, + focused, + scrolling, + offset, + ); + if focused && !scrolling { + cursor = Some(( + out.screen().cursor_position(), + out.screen().hide_cursor(), + )); + } + } + if let Some((pos, hide)) = cursor { + out.move_to(pos.0, pos.1); + out.hide_cursor(hide); + } + } + + pub fn entry(&self, idx: usize) -> &Entry { + &self.entries[idx] + } + + pub fn entry_mut(&mut self, idx: usize) -> &mut Entry { + &mut self.entries[idx] + } + + pub fn resize(&mut self, size: (u16, u16)) { + self.size = size; + for entry in &self.entries { + entry.resize(size); + } + } + + pub fn run( + &mut self, + cmdline: String, + env: Env, + event_w: crate::shell::event::Writer, + ) { + self.entries + .push(Entry::new(cmdline, env, self.size, event_w).unwrap()); + } + + pub fn entry_count(&self) -> usize { + self.entries.len() + } + + pub fn make_focus_visible( + &mut self, + repl_lines: usize, + focus: Option<usize>, + scrolling: bool, + ) { + if self.entries.is_empty() || focus.is_none() { + return; + } + let focus = focus.unwrap(); + + let mut done = false; + while focus + < self + .visible(repl_lines, Some(focus), scrolling) + .map(|(idx, ..)| idx) + .next() + .unwrap() + { + self.scroll_pos += 1; + done = true; + } + if done { + return; + } + + while focus + > self + .visible(repl_lines, Some(focus), scrolling) + .map(|(idx, ..)| idx) + .last() + .unwrap() + { + self.scroll_pos -= 1; + } + } + + pub async fn save(&self) { + // TODO: we'll probably want some amount of flock or something here + let mut fh = tokio::fs::OpenOptions::new() + .append(true) + .open(crate::dirs::history_file()) + .await + .unwrap(); + for entry in &self.entries { + fh.write_all( + format!( + ": {}:0;{}\n", + entry.start_time().unix_timestamp(), + entry.cmd() + ) + .as_bytes(), + ) + .await + .unwrap(); + } + } + + fn visible( + &self, + repl_lines: usize, + focus: Option<usize>, + scrolling: bool, + ) -> VisibleEntries { + let mut iter = VisibleEntries::new(); + let mut used_lines = repl_lines; + for (idx, entry) in + self.entries.iter().enumerate().rev().skip(self.scroll_pos) + { + let focused = focus.map_or(false, |focus| idx == focus); + used_lines += + entry.lines(self.entry_count(), focused && !scrolling); + if used_lines > usize::from(self.size.0) { + break; + } + iter.add(idx, used_lines, entry.lock_vt()); + } + iter + } +} + +struct VisibleEntries<'a> { + entries: std::collections::VecDeque<( + usize, + usize, + std::sync::MutexGuard<'a, pty::Vt>, + )>, +} + +impl<'a> VisibleEntries<'a> { + fn new() -> Self { + Self { + entries: std::collections::VecDeque::new(), + } + } + + fn add( + &mut self, + idx: usize, + offset: usize, + vt: std::sync::MutexGuard<'a, pty::Vt>, + ) { + // push_front because we are adding them in reverse order + self.entries.push_front((idx, offset, vt)); + } +} + +impl<'a> std::iter::Iterator for VisibleEntries<'a> { + type Item = (usize, usize, std::sync::MutexGuard<'a, pty::Vt>); + + fn next(&mut self) -> Option<Self::Item> { + self.entries.pop_front() + } +} + +impl<'a> std::iter::DoubleEndedIterator for VisibleEntries<'a> { + fn next_back(&mut self) -> Option<Self::Item> { + self.entries.pop_back() + } +} diff --git a/src/shell/history/pty.rs b/src/shell/history/pty.rs new file mode 100644 index 0000000..cef4ca9 --- /dev/null +++ b/src/shell/history/pty.rs @@ -0,0 +1,196 @@ +use crate::shell::prelude::*; + +#[derive(Debug)] +enum Request { + Input(Vec<u8>), + Resize(u16, u16), +} + +pub struct Pty { + vt: std::sync::Arc<std::sync::Mutex<Vt>>, + request_w: tokio::sync::mpsc::UnboundedSender<Request>, +} + +impl Pty { + pub fn new( + size: (u16, u16), + event_w: crate::shell::event::Writer, + ) -> Result<(Self, pty_process::Pts)> { + let (request_w, request_r) = tokio::sync::mpsc::unbounded_channel(); + + let pty = pty_process::Pty::new()?; + pty.resize(pty_process::Size::new(size.0, size.1))?; + let pts = pty.pts()?; + + let vt = std::sync::Arc::new(std::sync::Mutex::new(Vt::new(size))); + + tokio::spawn(Self::task( + pty, + std::sync::Arc::clone(&vt), + request_r, + event_w, + )); + + Ok((Self { vt, request_w }, pts)) + } + + pub fn with_vt<T>(&self, f: impl FnOnce(&Vt) -> T) -> T { + let vt = self.vt.lock().unwrap(); + f(&*vt) + } + + pub fn with_vt_mut<T>(&self, f: impl FnOnce(&mut Vt) -> T) -> T { + let mut vt = self.vt.lock().unwrap(); + f(&mut *vt) + } + + pub fn lock_vt(&self) -> std::sync::MutexGuard<Vt> { + self.vt.lock().unwrap() + } + + pub fn fullscreen(&self) -> bool { + self.with_vt(|vt| vt.screen().alternate_screen()) + } + + pub fn input(&self, bytes: Vec<u8>) { + #[allow(clippy::let_underscore_drop)] + let _ = self.request_w.send(Request::Input(bytes)); + } + + pub fn resize(&self, size: (u16, u16)) { + #[allow(clippy::let_underscore_drop)] + let _ = self.request_w.send(Request::Resize(size.0, size.1)); + } + + async fn task( + pty: pty_process::Pty, + vt: std::sync::Arc<std::sync::Mutex<Vt>>, + request_r: tokio::sync::mpsc::UnboundedReceiver<Request>, + event_w: crate::shell::event::Writer, + ) { + enum Res { + Read(Result<bytes::Bytes, std::io::Error>), + Request(Request), + } + + let (pty_r, mut pty_w) = pty.into_split(); + let mut stream: futures_util::stream::SelectAll<_> = [ + tokio_util::io::ReaderStream::new(pty_r) + .map(Res::Read) + .boxed(), + tokio_stream::wrappers::UnboundedReceiverStream::new(request_r) + .map(Res::Request) + .boxed(), + ] + .into_iter() + .collect(); + while let Some(res) = stream.next().await { + match res { + Res::Read(res) => match res { + Ok(bytes) => { + vt.lock().unwrap().process(&bytes); + event_w.send(Event::PtyOutput); + } + Err(e) => { + // this means that there are no longer any open pts + // fds. we could alternately signal this through an + // explicit channel at ChildExit time, but this seems + // reliable enough. + if e.raw_os_error() == Some(libc::EIO) { + return; + } + panic!("pty read failed: {:?}", e); + } + }, + Res::Request(Request::Input(bytes)) => { + pty_w.write(&bytes).await.unwrap(); + } + Res::Request(Request::Resize(row, col)) => { + pty_w.resize(pty_process::Size::new(row, col)).unwrap(); + vt.lock().unwrap().set_size((row, col)); + } + } + } + } +} + +pub struct Vt { + vt: vt100::Parser, + bell_state: usize, + bell: bool, + real_bell_pending: bool, +} + +impl Vt { + pub fn new(size: (u16, u16)) -> Self { + Self { + vt: vt100::Parser::new(size.0, size.1, 0), + bell_state: 0, + bell: false, + real_bell_pending: false, + } + } + + pub fn process(&mut self, bytes: &[u8]) { + self.vt.process(bytes); + let screen = self.vt.screen(); + + let new_bell_state = screen.audible_bell_count(); + if new_bell_state != self.bell_state { + self.bell = true; + self.real_bell_pending = true; + self.bell_state = new_bell_state; + } + } + + pub fn screen(&self) -> &vt100::Screen { + self.vt.screen() + } + + pub fn set_size(&mut self, size: (u16, u16)) { + self.vt.set_size(size.0, size.1); + } + + pub fn is_bell(&self) -> bool { + self.bell + } + + pub fn bell(&mut self, focused: bool) -> bool { + let mut should = false; + if self.real_bell_pending { + if self.bell { + should = true; + } + self.real_bell_pending = false; + } + if focused { + self.bell = false; + } + should + } + + pub fn binary(&self) -> bool { + self.vt.screen().errors() > 5 + } + + pub fn output_lines(&self, focused: bool, running: bool) -> usize { + if self.binary() { + return 1; + } + + let screen = self.vt.screen(); + let mut last_row = 0; + for (idx, row) in screen.rows(0, screen.size().1).enumerate() { + if !row.is_empty() { + last_row = idx + 1; + } + } + if focused && running { + last_row = std::cmp::max( + last_row, + usize::from(screen.cursor_position().0) + 1, + ); + } + last_row + } +} |