diff options
Diffstat (limited to 'src/shell')
-rw-r--r-- | src/shell/event.rs | 163 | ||||
-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 | ||||
-rw-r--r-- | src/shell/inputs/clock.rs | 27 | ||||
-rw-r--r-- | src/shell/inputs/git.rs | 274 | ||||
-rw-r--r-- | src/shell/inputs/mod.rs | 32 | ||||
-rw-r--r-- | src/shell/inputs/signals.rs | 30 | ||||
-rw-r--r-- | src/shell/inputs/stdin.rs | 17 | ||||
-rw-r--r-- | src/shell/mod.rs | 484 | ||||
-rw-r--r-- | src/shell/old_history.rs | 185 | ||||
-rw-r--r-- | src/shell/prelude.rs | 2 | ||||
-rw-r--r-- | src/shell/readline.rs | 223 |
13 files changed, 2270 insertions, 0 deletions
diff --git a/src/shell/event.rs b/src/shell/event.rs new file mode 100644 index 0000000..dc58e6f --- /dev/null +++ b/src/shell/event.rs @@ -0,0 +1,163 @@ +use crate::prelude::*; + +#[derive(Debug)] +pub enum Event { + Key(textmode::Key), + Resize((u16, u16)), + PtyOutput, + ChildRunPipeline(usize, (usize, usize)), + ChildSuspend(usize), + ChildExit(usize, super::history::ExitInfo, Option<Env>), + GitInfo(Option<super::inputs::GitInfo>), + ClockTimer, +} + +pub fn channel() -> (Writer, Reader) { + let (event_w, event_r) = tokio::sync::mpsc::unbounded_channel(); + (Writer::new(event_w), Reader::new(event_r)) +} + +#[derive(Clone)] +pub struct Writer(tokio::sync::mpsc::UnboundedSender<Event>); + +impl Writer { + pub fn new(event_w: tokio::sync::mpsc::UnboundedSender<Event>) -> Self { + Self(event_w) + } + + pub fn send(&self, event: Event) { + // the only time this should ever error is when the application is + // shutting down, at which point we don't actually care about any + // further dropped messages + #[allow(clippy::let_underscore_drop)] + let _ = self.0.send(event); + } +} + +pub struct Reader(std::sync::Arc<InnerReader>); + +impl Reader { + pub fn new( + mut input: tokio::sync::mpsc::UnboundedReceiver<Event>, + ) -> Self { + let inner = std::sync::Arc::new(InnerReader::new()); + { + let inner = inner.clone(); + tokio::spawn(async move { + while let Some(event) = input.recv().await { + inner.new_event(Some(event)); + } + inner.new_event(None); + }); + } + Self(inner) + } + + pub async fn recv(&self) -> Option<Event> { + self.0.recv().await + } +} + +struct InnerReader { + pending: std::sync::Mutex<Pending>, + cvar: tokio::sync::Notify, +} + +impl InnerReader { + fn new() -> Self { + Self { + pending: std::sync::Mutex::new(Pending::new()), + cvar: tokio::sync::Notify::new(), + } + } + + async fn recv(&self) -> Option<Event> { + loop { + if let Some(event) = self.pending.lock().unwrap().get_event() { + return event; + } + self.cvar.notified().await; + } + } + + fn new_event(&self, event: Option<Event>) { + self.pending.lock().unwrap().new_event(event); + self.cvar.notify_one(); + } +} + +#[allow(clippy::option_option)] +#[derive(Default)] +struct Pending { + key: std::collections::VecDeque<textmode::Key>, + size: Option<(u16, u16)>, + pty_output: bool, + child_run_pipeline: std::collections::VecDeque<(usize, (usize, usize))>, + child_suspend: std::collections::VecDeque<usize>, + child_exit: Option<(usize, super::history::ExitInfo, Option<Env>)>, + git_info: Option<Option<super::inputs::GitInfo>>, + clock_timer: bool, + done: bool, +} + +impl Pending { + fn new() -> Self { + Self::default() + } + + fn get_event(&mut self) -> Option<Option<Event>> { + if self.done { + return Some(None); + } + if let Some(key) = self.key.pop_front() { + return Some(Some(Event::Key(key))); + } + if let Some(size) = self.size.take() { + return Some(Some(Event::Resize(size))); + } + if let Some((idx, span)) = self.child_run_pipeline.pop_front() { + return Some(Some(Event::ChildRunPipeline(idx, span))); + } + if let Some(idx) = self.child_suspend.pop_front() { + return Some(Some(Event::ChildSuspend(idx))); + } + if let Some((idx, exit_info, env)) = self.child_exit.take() { + return Some(Some(Event::ChildExit(idx, exit_info, env))); + } + if let Some(info) = self.git_info.take() { + return Some(Some(Event::GitInfo(info))); + } + if self.clock_timer { + self.clock_timer = false; + return Some(Some(Event::ClockTimer)); + } + // process_output should be last because it will often be the case + // that there is ~always new process output (cat on large files, yes, + // etc) and that shouldn't prevent other events from happening + if self.pty_output { + self.pty_output = false; + return Some(Some(Event::PtyOutput)); + } + None + } + + fn new_event(&mut self, event: Option<Event>) { + match event { + Some(Event::Key(key)) => self.key.push_back(key), + Some(Event::Resize(size)) => self.size = Some(size), + Some(Event::PtyOutput) => self.pty_output = true, + Some(Event::ChildRunPipeline(idx, span)) => { + self.child_run_pipeline.push_back((idx, span)); + } + Some(Event::ChildSuspend(idx)) => { + self.child_suspend.push_back(idx); + } + Some(Event::ChildExit(idx, exit_info, env)) => { + self.child_exit = Some((idx, exit_info, env)); + } + Some(Event::GitInfo(info)) => self.git_info = Some(info), + Some(Event::ClockTimer) => self.clock_timer = true, + None => self.done = true, + } + } +} 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 + } +} diff --git a/src/shell/inputs/clock.rs b/src/shell/inputs/clock.rs new file mode 100644 index 0000000..250466e --- /dev/null +++ b/src/shell/inputs/clock.rs @@ -0,0 +1,27 @@ +use crate::shell::prelude::*; + +pub struct Handler; + +impl Handler { + pub fn new(event_w: crate::shell::event::Writer) -> Self { + tokio::spawn(Self::task(event_w)); + Self + } + + async fn task(event_w: crate::shell::event::Writer) { + let now_clock = time::OffsetDateTime::now_utc(); + let now_instant = tokio::time::Instant::now(); + let mut interval = tokio::time::interval_at( + now_instant + + std::time::Duration::from_nanos( + 1_000_000_000_u64 + .saturating_sub(now_clock.nanosecond().into()), + ), + std::time::Duration::from_secs(1), + ); + loop { + interval.tick().await; + event_w.send(Event::ClockTimer); + } + } +} diff --git a/src/shell/inputs/git.rs b/src/shell/inputs/git.rs new file mode 100644 index 0000000..dbae1c4 --- /dev/null +++ b/src/shell/inputs/git.rs @@ -0,0 +1,274 @@ +use crate::shell::prelude::*; + +use notify::Watcher as _; + +pub struct Handler { + git_w: tokio::sync::mpsc::UnboundedSender<std::path::PathBuf>, +} + +impl Handler { + pub fn new(event_w: crate::shell::event::Writer) -> Self { + let (git_w, git_r) = tokio::sync::mpsc::unbounded_channel(); + tokio::spawn(Self::task(git_r, event_w)); + Self { git_w } + } + + pub fn new_dir(&self, path: std::path::PathBuf) { + self.git_w.send(path).unwrap(); + } + + async fn task( + mut git_r: tokio::sync::mpsc::UnboundedReceiver<std::path::PathBuf>, + event_w: crate::shell::event::Writer, + ) { + // clippy can't tell that we assign to this later + #[allow(clippy::no_effect_underscore_binding)] + let mut _active_watcher = None; + while let Some(mut dir) = git_r.recv().await { + while let Ok(newer_dir) = git_r.try_recv() { + dir = newer_dir; + } + let repo = git2::Repository::discover(&dir).ok(); + if repo.is_some() { + let (sync_watch_w, sync_watch_r) = std::sync::mpsc::channel(); + let (watch_w, mut watch_r) = + tokio::sync::mpsc::unbounded_channel(); + let mut watcher = + notify::recommended_watcher(sync_watch_w).unwrap(); + watcher + .watch(&dir, notify::RecursiveMode::Recursive) + .unwrap(); + tokio::task::spawn_blocking(move || { + while let Ok(event) = sync_watch_r.recv() { + if watch_w.send(event).is_err() { + break; + } + } + }); + let event_w = event_w.clone(); + tokio::spawn(async move { + while watch_r.recv().await.is_some() { + let repo = git2::Repository::discover(&dir).ok(); + let info = tokio::task::spawn_blocking(|| { + repo.map(|repo| Info::new(&repo)) + }) + .await + .unwrap(); + event_w.send(Event::GitInfo(info)); + } + }); + _active_watcher = Some(watcher); + } else { + _active_watcher = None; + } + let info = tokio::task::spawn_blocking(|| { + repo.map(|repo| Info::new(&repo)) + }) + .await + .unwrap(); + event_w.send(Event::GitInfo(info)); + } + } +} + +#[derive(Debug)] +pub struct Info { + modified_files: bool, + staged_files: bool, + new_files: bool, + commits: bool, + active_operation: ActiveOperation, + branch: Option<String>, + remote_branch_diff: Option<(usize, usize)>, +} + +const MODIFIED: git2::Status = git2::Status::WT_DELETED + .union(git2::Status::WT_MODIFIED) + .union(git2::Status::WT_RENAMED) + .union(git2::Status::WT_TYPECHANGE) + .union(git2::Status::CONFLICTED); +const STAGED: git2::Status = git2::Status::INDEX_DELETED + .union(git2::Status::INDEX_MODIFIED) + .union(git2::Status::INDEX_NEW) + .union(git2::Status::INDEX_RENAMED) + .union(git2::Status::INDEX_TYPECHANGE); +const NEW: git2::Status = git2::Status::WT_NEW; + +impl Info { + pub fn new(git: &git2::Repository) -> Self { + let mut status_options = git2::StatusOptions::new(); + status_options.include_untracked(true); + status_options.update_index(true); + + let statuses = git.statuses(Some(&mut status_options)); + + let mut modified_files = false; + let mut staged_files = false; + let mut new_files = false; + if let Ok(statuses) = statuses { + for file in statuses.iter() { + if file.status().intersects(MODIFIED) { + modified_files = true; + } + if file.status().intersects(STAGED) { + staged_files = true; + } + if file.status().intersects(NEW) { + new_files = true; + } + } + } + + let head = git.head(); + let mut commits = false; + let mut branch = None; + let mut remote_branch_diff = None; + + if let Ok(head) = head { + commits = true; + if head.is_branch() { + branch = head.shorthand().map(ToString::to_string); + remote_branch_diff = + head.resolve() + .ok() + .map(|head| { + ( + head.target(), + head.shorthand().map(ToString::to_string), + ) + }) + .and_then(|(head_id, name)| { + head_id.and_then(|head_id| { + name.and_then(|name| { + git.refname_to_id(&format!( + "refs/remotes/origin/{}", + name + )) + .ok() + .and_then(|remote_id| { + git.graph_ahead_behind( + head_id, remote_id, + ) + .ok() + }) + }) + }) + }); + } else { + branch = + head.resolve().ok().and_then(|head| head.target()).map( + |oid| { + let mut sha: String = oid + .as_bytes() + .iter() + .take(4) + .map(|b| format!("{:02x}", b)) + .collect(); + sha.truncate(7); + sha + }, + ); + } + } + + let active_operation = match git.state() { + git2::RepositoryState::Merge => ActiveOperation::Merge, + git2::RepositoryState::Revert + | git2::RepositoryState::RevertSequence => { + ActiveOperation::Revert + } + git2::RepositoryState::CherryPick + | git2::RepositoryState::CherryPickSequence => { + ActiveOperation::CherryPick + } + git2::RepositoryState::Bisect => ActiveOperation::Bisect, + git2::RepositoryState::Rebase + | git2::RepositoryState::RebaseInteractive + | git2::RepositoryState::RebaseMerge => ActiveOperation::Rebase, + _ => ActiveOperation::None, + }; + + Self { + modified_files, + staged_files, + new_files, + commits, + active_operation, + branch, + remote_branch_diff, + } + } +} + +impl std::fmt::Display for Info { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "g")?; + + if self.modified_files { + write!(f, "*")?; + } + if self.staged_files { + write!(f, "+")?; + } + if self.new_files { + write!(f, "?")?; + } + if !self.commits { + write!(f, "!")?; + return Ok(()); + } + + let branch = self.branch.as_ref().map_or("???", |branch| { + if branch == "master" { + "" + } else { + branch + } + }); + if !branch.is_empty() { + write!(f, ":")?; + } + write!(f, "{}", branch)?; + + if let Some((local, remote)) = self.remote_branch_diff { + if local > 0 || remote > 0 { + write!(f, ":")?; + } + if local > 0 { + write!(f, "+{}", local)?; + } + if remote > 0 { + write!(f, "-{}", remote)?; + } + } else { + write!(f, ":-")?; + } + + write!(f, "{}", self.active_operation)?; + + Ok(()) + } +} + +#[derive(Debug, Copy, Clone)] +pub enum ActiveOperation { + None, + Merge, + Revert, + CherryPick, + Bisect, + Rebase, +} + +impl std::fmt::Display for ActiveOperation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ActiveOperation::None => Ok(()), + ActiveOperation::Merge => write!(f, "(m)"), + ActiveOperation::Revert => write!(f, "(v)"), + ActiveOperation::CherryPick => write!(f, "(c)"), + ActiveOperation::Bisect => write!(f, "(b)"), + ActiveOperation::Rebase => write!(f, "(r)"), + } + } +} diff --git a/src/shell/inputs/mod.rs b/src/shell/inputs/mod.rs new file mode 100644 index 0000000..48590a2 --- /dev/null +++ b/src/shell/inputs/mod.rs @@ -0,0 +1,32 @@ +use crate::shell::prelude::*; + +mod clock; +mod git; +pub use git::Info as GitInfo; +mod signals; +mod stdin; + +pub struct Handler { + _clock: clock::Handler, + git: git::Handler, + _signals: signals::Handler, + _stdin: stdin::Handler, +} + +impl Handler { + pub fn new( + input: textmode::blocking::Input, + event_w: crate::shell::event::Writer, + ) -> Result<Self> { + Ok(Self { + _clock: clock::Handler::new(event_w.clone()), + git: git::Handler::new(event_w.clone()), + _signals: signals::Handler::new(event_w.clone())?, + _stdin: stdin::Handler::new(input, event_w), + }) + } + + pub fn new_dir(&self, path: std::path::PathBuf) { + self.git.new_dir(path); + } +} diff --git a/src/shell/inputs/signals.rs b/src/shell/inputs/signals.rs new file mode 100644 index 0000000..4b91273 --- /dev/null +++ b/src/shell/inputs/signals.rs @@ -0,0 +1,30 @@ +use crate::shell::prelude::*; + +pub struct Handler; + +impl Handler { + pub fn new(event_w: crate::shell::event::Writer) -> Result<Self> { + let signals = tokio::signal::unix::signal( + tokio::signal::unix::SignalKind::window_change(), + )?; + tokio::spawn(Self::task(signals, event_w)); + Ok(Self) + } + + async fn task( + mut signals: tokio::signal::unix::Signal, + event_w: crate::shell::event::Writer, + ) { + event_w.send(resize_event()); + while signals.recv().await.is_some() { + event_w.send(resize_event()); + } + } +} + +fn resize_event() -> Event { + Event::Resize(terminal_size::terminal_size().map_or( + (24, 80), + |(terminal_size::Width(w), terminal_size::Height(h))| (h, w), + )) +} diff --git a/src/shell/inputs/stdin.rs b/src/shell/inputs/stdin.rs new file mode 100644 index 0000000..b966307 --- /dev/null +++ b/src/shell/inputs/stdin.rs @@ -0,0 +1,17 @@ +use crate::shell::prelude::*; + +pub struct Handler; + +impl Handler { + pub fn new( + mut input: textmode::blocking::Input, + event_w: crate::shell::event::Writer, + ) -> Self { + std::thread::spawn(move || { + while let Some(key) = input.read_key().unwrap() { + event_w.send(Event::Key(key)); + } + }); + Self + } +} diff --git a/src/shell/mod.rs b/src/shell/mod.rs new file mode 100644 index 0000000..fa7147b --- /dev/null +++ b/src/shell/mod.rs @@ -0,0 +1,484 @@ +use crate::shell::prelude::*; + +use textmode::Textmode as _; + +mod event; +mod history; +mod inputs; +mod old_history; +mod prelude; +mod readline; + +pub async fn main() -> Result<i32> { + let mut input = textmode::blocking::Input::new()?; + let mut output = textmode::Output::new().await?; + + // avoid the guards getting stuck in a task that doesn't run to + // completion + let _input_guard = input.take_raw_guard(); + let _output_guard = output.take_screen_guard(); + + let (event_w, event_r) = event::channel(); + + let inputs = inputs::Handler::new(input, event_w.clone()).unwrap(); + + let mut shell = Shell::new(crate::info::get_offset())?; + let mut prev_dir = shell.env.pwd().to_path_buf(); + inputs.new_dir(prev_dir.clone()); + while let Some(event) = event_r.recv().await { + match shell.handle_event(event, &event_w) { + Some(Action::Refresh) => { + shell.render(&mut output)?; + output.refresh().await?; + } + Some(Action::HardRefresh) => { + shell.render(&mut output)?; + output.hard_refresh().await?; + } + Some(Action::Resize(rows, cols)) => { + output.set_size(rows, cols); + shell.render(&mut output)?; + output.hard_refresh().await?; + } + Some(Action::Quit) => break, + None => {} + } + let dir = shell.env().pwd(); + if dir != prev_dir { + prev_dir = dir.to_path_buf(); + inputs.new_dir(dir.to_path_buf()); + } + } + + shell.history.save().await; + + Ok(0) +} + +#[derive(Copy, Clone, Debug)] +enum Focus { + Readline, + History(usize), + Scrolling(Option<usize>), +} + +#[derive(Copy, Clone, Debug)] +enum Scene { + Readline, + Fullscreen, +} + +pub enum Action { + Refresh, + HardRefresh, + Resize(u16, u16), + Quit, +} + +pub struct Shell { + readline: readline::Readline, + history: history::History, + old_history: old_history::History, + env: Env, + git: Option<inputs::GitInfo>, + focus: Focus, + scene: Scene, + escape: bool, + hide_readline: bool, + offset: time::UtcOffset, +} + +impl Shell { + pub fn new(offset: time::UtcOffset) -> Result<Self> { + let mut env = Env::new()?; + env.set_var("SHELL", std::env::current_exe()?); + env.set_var("TERM", "screen"); + Ok(Self { + readline: readline::Readline::new(), + history: history::History::new(), + old_history: old_history::History::new(), + env, + git: None, + focus: Focus::Readline, + scene: Scene::Readline, + escape: false, + hide_readline: false, + offset, + }) + } + + pub fn render(&self, out: &mut impl textmode::Textmode) -> Result<()> { + out.clear(); + out.write(&vt100::Parser::default().screen().input_mode_formatted()); + match self.scene { + Scene::Readline => match self.focus { + Focus::Readline => { + self.history.render( + out, + self.readline.lines(), + None, + false, + self.offset, + ); + self.readline.render( + out, + &self.env, + self.git.as_ref(), + true, + self.offset, + )?; + } + Focus::History(idx) => { + if self.hide_readline { + self.history.render( + out, + 0, + Some(idx), + false, + self.offset, + ); + } else { + self.history.render( + out, + self.readline.lines(), + Some(idx), + false, + self.offset, + ); + let pos = out.screen().cursor_position(); + self.readline.render( + out, + &self.env, + self.git.as_ref(), + false, + self.offset, + )?; + out.move_to(pos.0, pos.1); + } + } + Focus::Scrolling(idx) => { + self.history.render( + out, + self.readline.lines(), + idx, + true, + self.offset, + ); + self.readline.render( + out, + &self.env, + self.git.as_ref(), + idx.is_none(), + self.offset, + )?; + out.hide_cursor(true); + } + }, + Scene::Fullscreen => { + if let Focus::History(idx) = self.focus { + self.history.entry(idx).render_fullscreen(out); + } else { + unreachable!(); + } + } + } + Ok(()) + } + + pub fn handle_event( + &mut self, + event: Event, + event_w: &crate::shell::event::Writer, + ) -> Option<Action> { + match event { + Event::Key(key) => { + return if self.escape { + self.escape = false; + self.handle_key_escape(&key, event_w.clone()) + } else if key == textmode::Key::Ctrl(b'e') { + self.escape = true; + None + } else { + match self.focus { + Focus::Readline => { + self.handle_key_readline(&key, event_w.clone()) + } + Focus::History(idx) => { + self.handle_key_history(key, idx); + None + } + Focus::Scrolling(_) => { + self.handle_key_escape(&key, event_w.clone()) + } + } + }; + } + Event::Resize(new_size) => { + self.readline.resize(new_size); + self.history.resize(new_size); + return Some(Action::Resize(new_size.0, new_size.1)); + } + Event::PtyOutput => { + // the number of visible lines may have changed, so make sure + // the focus is still visible + self.history.make_focus_visible( + self.readline.lines(), + self.focus_idx(), + matches!(self.focus, Focus::Scrolling(_)), + ); + self.scene = self.default_scene(self.focus); + } + Event::ChildExit(idx, exit_info, env) => { + self.history.entry_mut(idx).exited(exit_info); + if self.focus_idx() == Some(idx) { + if let Some(env) = env { + if self.hide_readline { + let idx = self.env.idx(); + self.env = env; + self.env.set_idx(idx); + } + } + self.set_focus(if self.hide_readline { + Focus::Readline + } else { + Focus::Scrolling(Some(idx)) + }); + } + } + Event::ChildRunPipeline(idx, span) => { + self.history.entry_mut(idx).set_span(span); + } + Event::ChildSuspend(idx) => { + if self.focus_idx() == Some(idx) { + self.set_focus(Focus::Readline); + } + } + Event::GitInfo(info) => { + self.git = info; + } + Event::ClockTimer => {} + }; + Some(Action::Refresh) + } + + fn handle_key_escape( + &mut self, + key: &textmode::Key, + event_w: crate::shell::event::Writer, + ) -> Option<Action> { + match key { + textmode::Key::Ctrl(b'd') => { + return Some(Action::Quit); + } + textmode::Key::Ctrl(b'e') => { + self.set_focus(Focus::Scrolling(self.focus_idx())); + } + textmode::Key::Ctrl(b'l') => { + return Some(Action::HardRefresh); + } + textmode::Key::Ctrl(b'm') => { + if let Some(idx) = self.focus_idx() { + self.readline.clear_input(); + self.history.run( + self.history.entry(idx).cmd().to_string(), + self.env.clone(), + event_w, + ); + let idx = self.history.entry_count() - 1; + self.set_focus(Focus::History(idx)); + self.hide_readline = true; + self.env.set_idx(idx + 1); + } else { + self.set_focus(Focus::Readline); + } + } + textmode::Key::Char(' ') => { + if let Some(idx) = self.focus_idx() { + if self.history.entry(idx).running() { + self.set_focus(Focus::History(idx)); + } + } else { + self.set_focus(Focus::Readline); + } + } + textmode::Key::Char('e') => { + if let Focus::History(idx) = self.focus { + self.handle_key_history(textmode::Key::Ctrl(b'e'), idx); + } + } + textmode::Key::Char('f') => { + if let Some(idx) = self.focus_idx() { + let mut focus = Focus::History(idx); + let entry = self.history.entry_mut(idx); + if let Focus::Scrolling(_) = self.focus { + entry.set_fullscreen(true); + } else { + entry.toggle_fullscreen(); + if !entry.should_fullscreen() && !entry.running() { + focus = Focus::Scrolling(Some(idx)); + } + } + self.set_focus(focus); + } + } + textmode::Key::Char('i') => { + if let Some(idx) = self.focus_idx() { + self.readline + .set_input(self.history.entry(idx).cmd().to_string()); + self.set_focus(Focus::Readline); + } + } + textmode::Key::Char('j') | textmode::Key::Down => { + self.set_focus(Focus::Scrolling(self.scroll_down())); + } + textmode::Key::Char('k') | textmode::Key::Up => { + self.set_focus(Focus::Scrolling(self.scroll_up())); + } + textmode::Key::Char('n') => { + self.set_focus(self.next_running()); + } + textmode::Key::Char('p') => { + self.set_focus(self.prev_running()); + } + textmode::Key::Char('r') => { + self.set_focus(Focus::Readline); + } + _ => { + return None; + } + } + Some(Action::Refresh) + } + + fn handle_key_readline( + &mut self, + key: &textmode::Key, + event_w: crate::shell::event::Writer, + ) -> Option<Action> { + match key { + textmode::Key::Char(c) => { + self.readline.add_input(&c.to_string()); + } + textmode::Key::Ctrl(b'c') => self.readline.clear_input(), + textmode::Key::Ctrl(b'd') => { + return Some(Action::Quit); + } + textmode::Key::Ctrl(b'l') => { + return Some(Action::HardRefresh); + } + textmode::Key::Ctrl(b'm') => { + let input = self.readline.input(); + if !input.is_empty() { + self.history.run( + input.to_string(), + self.env.clone(), + event_w, + ); + let idx = self.history.entry_count() - 1; + self.set_focus(Focus::History(idx)); + self.hide_readline = true; + self.env.set_idx(idx + 1); + self.readline.clear_input(); + } + } + textmode::Key::Ctrl(b'u') => self.readline.clear_backwards(), + textmode::Key::Backspace => self.readline.backspace(), + textmode::Key::Left => self.readline.cursor_left(), + textmode::Key::Right => self.readline.cursor_right(), + textmode::Key::Up => { + let entry_count = self.history.entry_count(); + if entry_count > 0 { + self.set_focus(Focus::Scrolling(Some(entry_count - 1))); + } + } + _ => return None, + } + Some(Action::Refresh) + } + + fn handle_key_history(&mut self, key: textmode::Key, idx: usize) { + self.history.entry(idx).input(key.into_bytes()); + } + + fn default_scene(&self, focus: Focus) -> Scene { + match focus { + Focus::Readline | Focus::Scrolling(_) => Scene::Readline, + Focus::History(idx) => { + if self.history.entry(idx).should_fullscreen() { + Scene::Fullscreen + } else { + Scene::Readline + } + } + } + } + + fn set_focus(&mut self, new_focus: Focus) { + self.focus = new_focus; + self.hide_readline = false; + self.scene = self.default_scene(new_focus); + self.history.make_focus_visible( + self.readline.lines(), + self.focus_idx(), + matches!(self.focus, Focus::Scrolling(_)), + ); + } + + fn env(&self) -> &Env { + &self.env + } + + fn focus_idx(&self) -> Option<usize> { + match self.focus { + Focus::History(idx) => Some(idx), + Focus::Readline => None, + Focus::Scrolling(idx) => idx, + } + } + + fn scroll_up(&self) -> Option<usize> { + self.focus_idx().map_or_else( + || { + let count = self.history.entry_count(); + if count == 0 { + None + } else { + Some(count - 1) + } + }, + |idx| Some(idx.saturating_sub(1)), + ) + } + + fn scroll_down(&self) -> Option<usize> { + self.focus_idx().and_then(|idx| { + if idx >= self.history.entry_count() - 1 { + None + } else { + Some(idx + 1) + } + }) + } + + fn next_running(&self) -> Focus { + let count = self.history.entry_count(); + let cur = self.focus_idx().unwrap_or(count); + for idx in ((cur + 1)..count).chain(0..cur) { + if self.history.entry(idx).running() { + return Focus::History(idx); + } + } + self.focus + } + + fn prev_running(&self) -> Focus { + let count = self.history.entry_count(); + let cur = self.focus_idx().unwrap_or(count); + for idx in ((cur + 1)..count).chain(0..cur).rev() { + if self.history.entry(idx).running() { + return Focus::History(idx); + } + } + self.focus + } +} diff --git a/src/shell/old_history.rs b/src/shell/old_history.rs new file mode 100644 index 0000000..49fd1c2 --- /dev/null +++ b/src/shell/old_history.rs @@ -0,0 +1,185 @@ +use crate::shell::prelude::*; + +use tokio::io::AsyncBufReadExt as _; + +use pest::Parser as _; + +#[derive(pest_derive::Parser)] +#[grammar = "history.pest"] +struct HistoryLine; + +pub struct History { + entries: std::sync::Arc<std::sync::Mutex<Vec<Entry>>>, +} + +impl History { + pub fn new() -> Self { + let entries = std::sync::Arc::new(std::sync::Mutex::new(vec![])); + tokio::spawn(Self::task(std::sync::Arc::clone(&entries))); + Self { entries } + } + + pub fn entry_count(&self) -> usize { + self.entries.lock().unwrap().len() + } + + async fn task(entries: std::sync::Arc<std::sync::Mutex<Vec<Entry>>>) { + // TODO: we should actually read this in reverse order, because we + // want to populate the most recent entries first + let mut stream = tokio_stream::wrappers::LinesStream::new( + tokio::io::BufReader::new( + tokio::fs::File::open(crate::dirs::history_file()) + .await + .unwrap(), + ) + .lines(), + ); + while let Some(line) = stream.next().await { + let line = if let Ok(line) = line { + line + } else { + continue; + }; + let entry = if let Ok(entry) = line.parse() { + entry + } else { + continue; + }; + entries.lock().unwrap().push(entry); + } + } +} + +pub struct Entry { + cmdline: String, + start_time: Option<time::OffsetDateTime>, + duration: Option<std::time::Duration>, +} + +impl Entry { + pub fn render( + &self, + out: &mut impl textmode::Textmode, + offset: time::UtcOffset, + ) { + let size = out.screen().size(); + let mut time = "".to_string(); + if let Some(duration) = self.duration { + time.push_str(&crate::format::duration(duration)); + } + if let Some(start_time) = self.start_time { + time.push_str(&crate::format::time(start_time.to_offset(offset))); + } + + out.write_str(" $ "); + 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.cmdline.len() > max_len { + &self.cmdline[..(max_len - 4)] + } else { + &self.cmdline + }; + out.write_str(cmd); + if self.cmdline.len() > max_len { + out.write_str(" "); + out.set_fgcolor(textmode::color::BLUE); + out.write_str("..."); + } + out.reset_attributes(); + + out.set_bgcolor(textmode::Color::Rgb(0x20, 0x20, 0x20)); + 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(); + } + + pub fn cmd(&self) -> &str { + &self.cmdline + } +} + +impl std::str::FromStr for Entry { + type Err = anyhow::Error; + + fn from_str(line: &str) -> std::result::Result<Self, Self::Err> { + let mut parsed = + HistoryLine::parse(Rule::line, line).map_err(|e| anyhow!(e))?; + let line = parsed.next().unwrap(); + assert!(matches!(line.as_rule(), Rule::line)); + + let mut start_time = None; + let mut duration = None; + let mut cmdline = None; + for part in line.into_inner() { + match part.as_rule() { + Rule::time => { + start_time = + Some(time::OffsetDateTime::from_unix_timestamp( + part.as_str().parse()?, + )?); + } + Rule::duration => { + if part.as_str() == "0" { + continue; + } + let mut dur_parts = part.as_str().split('.'); + let secs: u64 = dur_parts.next().unwrap().parse()?; + let nsec_str = dur_parts.next().unwrap_or("0"); + let nsec_str = &nsec_str[..9.min(nsec_str.len())]; + let nsecs: u64 = nsec_str.parse()?; + duration = Some(std::time::Duration::from_nanos( + secs * 1_000_000_000 + + nsecs + * (10u64.pow( + (9 - nsec_str.len()).try_into().unwrap(), + )), + )); + } + Rule::command => { + cmdline = Some(part.as_str().to_string()); + } + Rule::line => unreachable!(), + Rule::EOI => break, + } + } + + Ok(Self { + cmdline: cmdline.unwrap(), + start_time, + duration, + }) + } +} + +#[test] +fn test_parse() { + let entry: Entry = + ": 1646779848:1234.56;vim ~/.zsh_history".parse().unwrap(); + assert_eq!(entry.cmdline, "vim ~/.zsh_history"); + assert_eq!( + entry.duration, + Some(std::time::Duration::from_nanos(1_234_560_000_000)) + ); + assert_eq!( + entry.start_time, + Some(time::macros::datetime!(2022-03-08 22:50:48).assume_utc()) + ); + + let entry: Entry = ": 1646779848:1;vim ~/.zsh_history".parse().unwrap(); + assert_eq!(entry.cmdline, "vim ~/.zsh_history"); + assert_eq!(entry.duration, Some(std::time::Duration::from_secs(1))); + assert_eq!( + entry.start_time, + Some(time::macros::datetime!(2022-03-08 22:50:48).assume_utc()) + ); + + let entry: Entry = "vim ~/.zsh_history".parse().unwrap(); + assert_eq!(entry.cmdline, "vim ~/.zsh_history"); + assert_eq!(entry.duration, None); + assert_eq!(entry.start_time, None); +} diff --git a/src/shell/prelude.rs b/src/shell/prelude.rs new file mode 100644 index 0000000..73897bc --- /dev/null +++ b/src/shell/prelude.rs @@ -0,0 +1,2 @@ +pub use super::event::Event; +pub use crate::prelude::*; diff --git a/src/shell/readline.rs b/src/shell/readline.rs new file mode 100644 index 0000000..654d264 --- /dev/null +++ b/src/shell/readline.rs @@ -0,0 +1,223 @@ +use crate::shell::prelude::*; + +use unicode_width::{UnicodeWidthChar as _, UnicodeWidthStr as _}; + +pub struct Readline { + size: (u16, u16), + input_line: String, + scroll: usize, + pos: usize, +} + +impl Readline { + pub fn new() -> Self { + Self { + size: (24, 80), + input_line: "".into(), + scroll: 0, + pos: 0, + } + } + + pub fn render( + &self, + out: &mut impl textmode::Textmode, + env: &Env, + git: Option<&super::inputs::GitInfo>, + focus: bool, + offset: time::UtcOffset, + ) -> Result<()> { + let pwd = env.pwd(); + let user = crate::info::user()?; + let hostname = crate::info::hostname()?; + let time = crate::info::time(offset)?; + let prompt_char = crate::info::prompt_char()?; + + let id = format!("{}@{}", user, hostname); + let idlen: u16 = id.len().try_into().unwrap(); + let timelen: u16 = time.len().try_into().unwrap(); + + out.move_to(self.size.0 - 2, 0); + if focus { + out.set_bgcolor(textmode::Color::Rgb(0x56, 0x1b, 0x8b)); + } else if env.idx() % 2 == 0 { + out.set_bgcolor(textmode::Color::Rgb(0x24, 0x21, 0x00)); + } else { + out.set_bgcolor(textmode::Color::Rgb(0x20, 0x20, 0x20)); + } + out.write(b"\x1b[K"); + out.set_fgcolor(textmode::color::YELLOW); + out.write_str(&format!("{}", env.idx() + 1)); + out.reset_attributes(); + if focus { + out.set_bgcolor(textmode::Color::Rgb(0x56, 0x1b, 0x8b)); + } else if env.idx() % 2 == 0 { + out.set_bgcolor(textmode::Color::Rgb(0x24, 0x21, 0x00)); + } else { + out.set_bgcolor(textmode::Color::Rgb(0x20, 0x20, 0x20)); + } + out.write_str(" ("); + out.write_str(&crate::format::path(pwd)); + if let Some(info) = git { + out.write_str(&format!("|{}", info)); + } + out.write_str(")"); + out.move_to(self.size.0 - 2, self.size.1 - 4 - idlen - timelen); + out.write_str(&id); + out.write_str(" ["); + out.write_str(&time); + out.write_str("]"); + + out.move_to(self.size.0 - 1, 0); + out.reset_attributes(); + out.write_str(&prompt_char); + out.write_str(" "); + out.reset_attributes(); + out.write(b"\x1b[K"); + out.write_str(self.visible_input()); + out.reset_attributes(); + out.move_to(self.size.0 - 1, 2 + self.pos_width()); + if focus { + out.hide_cursor(false); + } + Ok(()) + } + + pub fn resize(&mut self, size: (u16, u16)) { + self.size = size; + } + + // self will be used eventually + #[allow(clippy::unused_self)] + pub fn lines(&self) -> usize { + 2 // XXX handle wrapping + } + + pub fn input(&self) -> &str { + &self.input_line + } + + pub fn add_input(&mut self, s: &str) { + self.input_line.insert_str(self.byte_pos(), s); + self.inc_pos(s.chars().count()); + } + + pub fn set_input(&mut self, s: String) { + self.set_pos(s.chars().count()); + self.input_line = s; + } + + pub fn backspace(&mut self) { + while self.pos > 0 { + self.dec_pos(1); + let width = + self.input_line.remove(self.byte_pos()).width().unwrap_or(0); + if width > 0 { + break; + } + } + } + + pub fn clear_input(&mut self) { + self.input_line.clear(); + self.set_pos(0); + } + + pub fn clear_backwards(&mut self) { + self.input_line = self.input_line.chars().skip(self.pos).collect(); + self.set_pos(0); + } + + pub fn cursor_left(&mut self) { + if self.pos == 0 { + return; + } + self.dec_pos(1); + while let Some(c) = self.input_line.chars().nth(self.pos) { + if c.width().unwrap_or(0) == 0 { + self.dec_pos(1); + } else { + break; + } + } + } + + pub fn cursor_right(&mut self) { + if self.pos == self.input_line.chars().count() { + return; + } + self.inc_pos(1); + while let Some(c) = self.input_line.chars().nth(self.pos) { + if c.width().unwrap_or(0) == 0 { + self.inc_pos(1); + } else { + break; + } + } + } + + fn set_pos(&mut self, pos: usize) { + self.pos = pos; + if self.pos < self.scroll || self.pos_width() > self.size.1 - 2 { + self.scroll = self.pos; + let mut extra_scroll = usize::from(self.size.1) / 2; + while extra_scroll > 0 && self.scroll > 0 { + self.scroll -= 1; + extra_scroll -= self + .input_line + .chars() + .nth(self.scroll) + .unwrap() + .width() + .unwrap_or(1); + } + } + } + + fn inc_pos(&mut self, inc: usize) { + self.set_pos(self.pos + inc); + } + + fn dec_pos(&mut self, dec: usize) { + self.set_pos(self.pos - dec); + } + + fn pos_width(&self) -> u16 { + let start = self + .input_line + .char_indices() + .nth(self.scroll) + .map_or(self.input_line.len(), |(i, _)| i); + let end = self + .input_line + .char_indices() + .nth(self.pos) + .map_or(self.input_line.len(), |(i, _)| i); + self.input_line[start..end].width().try_into().unwrap() + } + + fn byte_pos(&self) -> usize { + self.input_line + .char_indices() + .nth(self.pos) + .map_or(self.input_line.len(), |(i, _)| i) + } + + fn visible_input(&self) -> &str { + let start = self + .input_line + .char_indices() + .nth(self.scroll) + .map_or(self.input_line.len(), |(i, _)| i); + let mut end = self.input_line.len(); + let mut width = 0; + for (i, c) in self.input_line.char_indices().skip(self.scroll) { + if width >= usize::from(self.size.1) - 2 { + end = i; + break; + } + width += c.width().unwrap_or(1); + } + &self.input_line[start..end] + } +} |