summaryrefslogtreecommitdiffstats
path: root/src/shell
diff options
context:
space:
mode:
Diffstat (limited to 'src/shell')
-rw-r--r--src/shell/event.rs163
-rw-r--r--src/shell/history/entry.rs429
-rw-r--r--src/shell/history/mod.rs208
-rw-r--r--src/shell/history/pty.rs196
-rw-r--r--src/shell/inputs/clock.rs27
-rw-r--r--src/shell/inputs/git.rs274
-rw-r--r--src/shell/inputs/mod.rs32
-rw-r--r--src/shell/inputs/signals.rs30
-rw-r--r--src/shell/inputs/stdin.rs17
-rw-r--r--src/shell/mod.rs484
-rw-r--r--src/shell/old_history.rs185
-rw-r--r--src/shell/prelude.rs2
-rw-r--r--src/shell/readline.rs223
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]
+ }
+}