Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
term: Implement content-aware pager for `rad diff`
Merged did:key:z6MksFqX...wzpT opened 1 year ago

See commits.

18 files changed +326 -65 104a1b96 6dd52c94
modified Cargo.lock
@@ -2551,6 +2551,7 @@ dependencies = [
 "radicle-crypto",
 "radicle-fetch",
 "radicle-git-ext",
+
 "radicle-signals",
 "scrypt",
 "serde",
 "serde_json",
@@ -2573,6 +2574,14 @@ dependencies = [
]

[[package]]
+
name = "radicle-signals"
+
version = "0.9.0"
+
dependencies = [
+
 "crossbeam-channel",
+
 "libc",
+
]
+

+
[[package]]
name = "radicle-ssh"
version = "0.9.0"
dependencies = [
@@ -2614,14 +2623,17 @@ version = "0.9.0"
dependencies = [
 "anstyle-query",
 "anyhow",
+
 "crossbeam-channel",
 "git2",
 "inquire",
 "libc",
 "once_cell",
 "pretty_assertions",
+
 "radicle-signals",
 "shlex",
 "tempfile",
 "termion 3.0.0",
+
 "thiserror",
 "unicode-display-width",
 "unicode-segmentation",
 "zeroize",
@@ -2635,6 +2647,7 @@ dependencies = [
 "radicle",
 "radicle-cli",
 "radicle-git-ext",
+
 "radicle-term",
]

[[package]]
modified Cargo.toml
@@ -14,6 +14,7 @@ members = [
  "radicle-remote-helper",
  "radicle-ssh",
  "radicle-tools",
+
  "radicle-signals",
]
default-members = [
  "radicle",
@@ -25,6 +26,7 @@ default-members = [
  "radicle-ssh",
  "radicle-remote-helper",
  "radicle-term",
+
  "radicle-signals",
]
resolver = "2"

modified radicle-cli/src/commands/diff.rs
@@ -8,7 +8,6 @@ use radicle_surf as surf;

use crate::git::pretty_diff::ToPretty as _;
use crate::git::Rev;
-
use crate::pager;
use crate::terminal as term;
use crate::terminal::args::{Args, Error, Help};
use crate::terminal::highlight::Highlighter;
@@ -145,7 +144,7 @@ pub fn run(options: Options, _ctx: impl term::Context) -> anyhow::Result<()> {
    let mut hi = Highlighter::default();
    let pretty = diff.pretty(&mut hi, &(), &repo);

-
    pager::run(pretty)?;
+
    term::pager::page(pretty)?;

    Ok(())
}
modified radicle-node/Cargo.toml
@@ -50,6 +50,10 @@ features = ["logger"]
path = "../radicle-fetch"
version = "0.9.0"

+
[dependencies.radicle-signals]
+
path = "../radicle-signals"
+
version = "0"
+

[dev-dependencies]
radicle = { path = "../radicle", version = "0", features = ["test"] }
radicle-crypto = { path = "../radicle-crypto", version = "0", features = ["test", "cyphernet"] }
modified radicle-node/src/lib.rs
@@ -3,7 +3,6 @@ pub mod control;
pub mod deserializer;
pub mod runtime;
pub mod service;
-
pub mod signals;
#[cfg(any(test, feature = "test"))]
pub mod test;
#[cfg(test)]
modified radicle-node/src/main.rs
@@ -9,8 +9,8 @@ use radicle::prelude::Signer;
use radicle::profile;
use radicle::version::Version;
use radicle_node::crypto::ssh::keystore::{Keystore, MemorySigner};
-
use radicle_node::signals;
use radicle_node::Runtime;
+
use radicle_signals as signals;

pub const VERSION: Version = Version {
    name: env!("CARGO_PKG_NAME"),
modified radicle-node/src/runtime.rs
@@ -10,6 +10,7 @@ use crossbeam_channel as chan;
use cyphernet::Ecdh;
use netservices::resource::NetAccept;
use radicle_fetch::FetchLimit;
+
use radicle_signals::Signal;
use reactor::poller::popol;
use reactor::Reactor;
use thiserror::Error;
@@ -128,7 +129,7 @@ pub struct Runtime {
    pub reactor: Reactor<wire::Control, popol::Poller>,
    pub pool: worker::Pool,
    pub local_addrs: Vec<net::SocketAddr>,
-
    pub signals: chan::Receiver<()>,
+
    pub signals: chan::Receiver<Signal>,
}

impl Runtime {
@@ -139,7 +140,7 @@ impl Runtime {
        home: Home,
        config: service::Config,
        listen: Vec<net::SocketAddr>,
-
        signals: chan::Receiver<()>,
+
        signals: chan::Receiver<Signal>,
        signer: G,
    ) -> Result<Runtime, Error>
    where
@@ -306,7 +307,7 @@ impl Runtime {
            || control::listen(self.control, handle)
        });
        let _signals = thread::spawn(&self.id, "signals", move || {
-
            if let Ok(()) = self.signals.recv() {
+
            if let Ok(Signal::Terminate | Signal::Interrupt) = self.signals.recv() {
                log::info!(target: "node", "Termination signal received; shutting down..");
                self.handle.shutdown().ok();
            }
deleted radicle-node/src/signals.rs
@@ -1,55 +0,0 @@
-
use std::io;
-
use std::sync::Mutex;
-

-
use crossbeam_channel as chan;
-

-
/// Signal notifications are sent via this channel.
-
static NOTIFY: Mutex<Option<chan::Sender<()>>> = Mutex::new(None);
-

-
/// Install global signal handlers for `SIGTERM` and `SIGINT`.
-
pub fn install(notify: chan::Sender<()>) -> io::Result<()> {
-
    if let Ok(mut channel) = NOTIFY.try_lock() {
-
        if channel.is_some() {
-
            return Err(io::Error::new(
-
                io::ErrorKind::AlreadyExists,
-
                "signal handler is already installed",
-
            ));
-
        }
-
        *channel = Some(notify);
-

-
        unsafe { _install() }?;
-
    } else {
-
        return Err(io::Error::new(
-
            io::ErrorKind::WouldBlock,
-
            "unable to install signal handler",
-
        ));
-
    }
-
    Ok(())
-
}
-

-
/// Install global signal handlers for `SIGTERM` and `SIGINT`.
-
///
-
/// # Safety
-
///
-
/// Calls `libc` functions safely.
-
unsafe fn _install() -> io::Result<()> {
-
    if libc::signal(libc::SIGTERM, handler as libc::sighandler_t) == libc::SIG_ERR {
-
        return Err(io::Error::last_os_error());
-
    }
-
    if libc::signal(libc::SIGINT, handler as libc::sighandler_t) == libc::SIG_ERR {
-
        return Err(io::Error::last_os_error());
-
    }
-
    Ok(())
-
}
-

-
/// Called by `libc` when a signal is received.
-
extern "C" fn handler(sig: libc::c_int, _info: *mut libc::siginfo_t, _data: *mut libc::c_void) {
-
    if sig != libc::SIGTERM && sig != libc::SIGINT {
-
        return;
-
    }
-
    if let Ok(guard) = NOTIFY.try_lock() {
-
        if let Some(c) = &*guard {
-
            c.try_send(()).ok();
-
        }
-
    }
-
}
added radicle-signals/Cargo.toml
@@ -0,0 +1,10 @@
+
[package]
+
name = "radicle-signals"
+
homepage = "https://radicle.xyz"
+
repository = "https://app.radicle.xyz/seeds/seed.radicle.xyz/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5"
+
edition = "2021"
+
version = "0.9.0"
+

+
[dependencies]
+
crossbeam-channel = { version = "0.5.6" }
+
libc = { version = "0.2" }
added radicle-signals/src/lib.rs
@@ -0,0 +1,88 @@
+
use std::io;
+
use std::sync::Mutex;
+

+
use crossbeam_channel as chan;
+

+
/// Operating system signal.
+
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
+
pub enum Signal {
+
    /// `SIGINT`.
+
    Interrupt,
+
    /// `SIGTERM`.
+
    Terminate,
+
    /// `SIGHUP`.
+
    Hangup,
+
    /// `SIGWINCH`.
+
    WindowChanged,
+
}
+

+
impl TryFrom<i32> for Signal {
+
    type Error = i32;
+

+
    fn try_from(value: i32) -> Result<Self, Self::Error> {
+
        match value {
+
            libc::SIGTERM => Ok(Self::Terminate),
+
            libc::SIGINT => Ok(Self::Interrupt),
+
            libc::SIGWINCH => Ok(Self::WindowChanged),
+
            libc::SIGHUP => Ok(Self::Hangup),
+
            _ => Err(value),
+
        }
+
    }
+
}
+

+
/// Signal notifications are sent via this channel.
+
static NOTIFY: Mutex<Option<chan::Sender<Signal>>> = Mutex::new(None);
+

+
/// Install global signal handlers.
+
pub fn install(notify: chan::Sender<Signal>) -> io::Result<()> {
+
    if let Ok(mut channel) = NOTIFY.try_lock() {
+
        if channel.is_some() {
+
            return Err(io::Error::new(
+
                io::ErrorKind::AlreadyExists,
+
                "signal handler is already installed",
+
            ));
+
        }
+
        *channel = Some(notify);
+

+
        unsafe { _install() }?;
+
    } else {
+
        return Err(io::Error::new(
+
            io::ErrorKind::WouldBlock,
+
            "unable to install signal handler",
+
        ));
+
    }
+
    Ok(())
+
}
+

+
/// Install global signal handlers.
+
///
+
/// # Safety
+
///
+
/// Calls `libc` functions safely.
+
unsafe fn _install() -> io::Result<()> {
+
    if libc::signal(libc::SIGTERM, handler as libc::sighandler_t) == libc::SIG_ERR {
+
        return Err(io::Error::last_os_error());
+
    }
+
    if libc::signal(libc::SIGINT, handler as libc::sighandler_t) == libc::SIG_ERR {
+
        return Err(io::Error::last_os_error());
+
    }
+
    if libc::signal(libc::SIGHUP, handler as libc::sighandler_t) == libc::SIG_ERR {
+
        return Err(io::Error::last_os_error());
+
    }
+
    if libc::signal(libc::SIGWINCH, handler as libc::sighandler_t) == libc::SIG_ERR {
+
        return Err(io::Error::last_os_error());
+
    }
+
    Ok(())
+
}
+

+
/// Called by `libc` when a signal is received.
+
extern "C" fn handler(sig: libc::c_int, _info: *mut libc::siginfo_t, _data: *mut libc::c_void) {
+
    let Ok(sig) = sig.try_into() else {
+
        return;
+
    };
+
    if let Ok(guard) = NOTIFY.try_lock() {
+
        if let Some(c) = &*guard {
+
            c.try_send(sig).ok();
+
        }
+
    }
+
}
modified radicle-term/Cargo.toml
@@ -14,11 +14,13 @@ default = ["git2"]
[dependencies]
anyhow = { version = "1" }
anstyle-query = { version = "1.0.0" }
+
crossbeam-channel = { version = "0.5.6" }
inquire = { version = "0.7.4", default-features = false, features = ["termion", "editor"] }
libc = { version = "0.2" }
once_cell = { version = "1.13" }
shlex = { version = "1.1" }
termion = { version = "3" }
+
thiserror = { version = "1" }
unicode-display-width = { version = "0.3.0" }
unicode-segmentation = { version = "1.7.1" }
zeroize = { version = "1.1" }
@@ -29,6 +31,10 @@ default-features = false
features = ["vendored-libgit2"]
optional = true

+
[dependencies.radicle-signals]
+
path = "../radicle-signals"
+
version = "0"
+

[dev-dependencies]
pretty_assertions = { version = "1.3.0" }
tempfile = { version = "3.3.0" }
modified radicle-term/src/element.rs
@@ -64,7 +64,7 @@ impl Constraint {
}

/// A text element that has a size and can be rendered to the terminal.
-
pub trait Element: fmt::Debug {
+
pub trait Element: fmt::Debug + Send + Sync {
    /// Get the size of the element, in rows and columns.
    fn size(&self, parent: Constraint) -> Size;

modified radicle-term/src/lib.rs
@@ -8,6 +8,7 @@ pub mod format;
pub mod hstack;
pub mod io;
pub mod label;
+
pub mod pager;
pub mod spinner;
pub mod table;
pub mod textarea;
added radicle-term/src/pager.rs
@@ -0,0 +1,176 @@
+
use std::io::{IsTerminal, Write};
+
use std::{io, thread};
+

+
use crate::element::Size;
+
use crate::{Constraint, Element, Line, Paint};
+

+
use crossbeam_channel as chan;
+
use radicle_signals as signals;
+
use termion::event::{Event, Key, MouseButton, MouseEvent};
+
use termion::{input::TermRead, raw::IntoRawMode, screen::IntoAlternateScreen};
+

+
/// How many lines to scroll when the mouse wheel is used.
+
const MOUSE_SCROLL_LINES: usize = 3;
+

+
/// Pager error.
+
#[derive(Debug, thiserror::Error)]
+
pub enum Error {
+
    #[error(transparent)]
+
    Io(#[from] io::Error),
+
    #[error(transparent)]
+
    Channel(#[from] chan::RecvError),
+
}
+

+
/// A pager for the given element. Re-renders the element when the terminal is resized so that
+
/// it doesn't wrap. If the output device is not a TTY, just prints the element via
+
/// [`Element::print`].
+
pub fn page<E: Element + Send + 'static>(element: E) -> Result<(), Error> {
+
    let (events_tx, events_rx) = chan::unbounded();
+
    let (signals_tx, signals_rx) = chan::unbounded();
+

+
    signals::install(signals_tx)?;
+

+
    thread::spawn(move || {
+
        for e in io::stdin().events() {
+
            events_tx.send(e).ok();
+
        }
+
    });
+
    thread::spawn(move || main(element, signals_rx, events_rx))
+
        .join()
+
        .unwrap()
+
}
+

+
fn main<E: Element>(
+
    element: E,
+
    signals_rx: chan::Receiver<signals::Signal>,
+
    events_rx: chan::Receiver<Result<Event, io::Error>>,
+
) -> Result<(), Error> {
+
    let stdout = io::stdout();
+
    if !stdout.is_terminal() {
+
        element.print();
+
        return Ok(());
+
    }
+
    let raw = stdout.into_raw_mode()?;
+
    let mut stdout = termion::input::MouseTerminal::from(raw).into_alternate_screen()?;
+
    let (mut width, mut height) = termion::terminal_size()?;
+
    let mut lines = element.render(Constraint::max(Size::new(width as usize, height as usize)));
+
    let mut line = 0;
+

+
    render(&mut stdout, lines.as_slice(), line, (width, height))?;
+

+
    loop {
+
        chan::select! {
+
            recv(signals_rx) -> signal => {
+
                match signal? {
+
                    signals::Signal::WindowChanged => {
+
                        let (w, h) = termion::terminal_size()?;
+

+
                        lines = element.render(Constraint::max(Size::new(w as usize, h as usize)));
+
                        width = w;
+
                        height = h;
+
                    }
+
                    signals::Signal::Interrupt | signals::Signal::Terminate => {
+
                        break;
+
                    }
+
                    _ => continue,
+
                }
+
            }
+
            recv(events_rx) -> event => {
+
                let event = event??;
+
                let page = height as usize - 1; // Don't count the status bar.
+
                let end = lines.len() - page;
+
                let prev = line;
+

+
                match event {
+
                    Event::Key(key) => match key {
+
                        Key::Up | Key::Char('k') => {
+
                            line = line.saturating_sub(1);
+
                        }
+
                        Key::Home => {
+
                            line = 0;
+
                        }
+
                        Key::End | Key::Char('G') => {
+
                            line = end;
+
                        }
+
                        Key::PageUp | Key::Char('b') => {
+
                            line = line.saturating_sub(page);
+
                        }
+
                        Key::PageDown | Key::Char(' ') => {
+
                            line = (line + page).min(end);
+
                        }
+
                        Key::Down | Key::Char('j') => {
+
                            if line < end {
+
                                line += 1;
+
                            }
+
                        }
+
                        Key::Char('q') => break,
+

+
                        _ => continue,
+
                    }
+
                    Event::Mouse(MouseEvent::Press(MouseButton::WheelDown, _, _)) => {
+
                        if line < end {
+
                            line += MOUSE_SCROLL_LINES;
+
                        }
+
                    }
+
                    Event::Mouse(MouseEvent::Press(MouseButton::WheelUp, _, _)) => {
+
                        line = line.saturating_sub(MOUSE_SCROLL_LINES);
+
                    }
+
                    _ => continue,
+
                }
+
                // Don't re-render if there's no change in line.
+
                if line == prev {
+
                    continue;
+
                }
+
            }
+
        }
+
        render(&mut stdout, &lines, line, (width, height))?;
+
    }
+
    Ok(())
+
}
+

+
fn render<W: Write>(
+
    out: &mut W,
+
    lines: &[Line],
+
    start_line: usize,
+
    (width, height): (u16, u16),
+
) -> io::Result<()> {
+
    write!(
+
        out,
+
        "{}{}",
+
        termion::clear::All,
+
        termion::cursor::Goto(1, 1)
+
    )?;
+

+
    let content_length = lines.len();
+
    let window_size = height as usize - 1;
+
    let end_line = if start_line + window_size > content_length {
+
        content_length
+
    } else {
+
        start_line + window_size
+
    };
+
    // Render content.
+
    for (ix, line) in lines[start_line..end_line].iter().enumerate() {
+
        write!(out, "{}{}", termion::cursor::Goto(1, ix as u16 + 1), line)?;
+
    }
+
    // Render progress meter.
+
    write!(
+
        out,
+
        "{}{}",
+
        termion::cursor::Goto(width - 3, height),
+
        Paint::new(format!(
+
            "{:.0}%",
+
            end_line as f64 / lines.len() as f64 * 100.
+
        ))
+
        .dim()
+
    )?;
+
    // Render cursor input area.
+
    write!(
+
        out,
+
        "{}{}",
+
        termion::cursor::Goto(1, height),
+
        Paint::new(":").dim()
+
    )?;
+
    out.flush()?;
+

+
    Ok(())
+
}
modified radicle-term/src/table.rs
@@ -78,7 +78,7 @@ impl<const W: usize, T> Default for Table<W, T> {
    }
}

-
impl<const W: usize, T: Cell + fmt::Debug> Element for Table<W, T>
+
impl<const W: usize, T: Cell + fmt::Debug + Send + Sync> Element for Table<W, T>
where
    T::Padded: Into<Line>,
{
modified radicle-tools/Cargo.toml
@@ -19,6 +19,10 @@ path = "../radicle"
version = "0"
path = "../radicle-cli"

+
[dependencies.radicle-term]
+
version = "0"
+
path = "../radicle-term"
+

[[bin]]
name = "rad-init"
path = "src/rad-init.rs"
modified radicle-tools/src/rad-cli-demo.rs
@@ -7,6 +7,7 @@ fn main() -> anyhow::Result<()> {
        "Choose something to try out:",
        &[
            "confirm",
+
            "pager",
            "spinner",
            "spinner-drop",
            "spinner-error",
@@ -22,6 +23,17 @@ fn main() -> anyhow::Result<()> {
                terminal::success!("You said 'yes'");
            }
        }
+
        "pager" => {
+
            let mut table = radicle_term::Table::<1, radicle_term::Label>::new(
+
                radicle_term::TableOptions::bordered(),
+
            );
+
            let rows = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/rad-cli-demo.rs"));
+

+
            for row in rows.lines() {
+
                table.push([row.into()]);
+
            }
+
            radicle_term::pager::page(table)?;
+
        }
        "editor" => {
            let output = terminal::editor::Editor::new()
                .extension("rs")
modified radicle/src/profile.rs
@@ -113,7 +113,8 @@ pub mod env {
        }
    }

-
    /// Return the seed stored in the [`RAD_SEED`] environment variable, or generate a random one.
+
    /// Return the seed stored in the [`RAD_KEYGEN_SEED`] environment variable,
+
    /// or generate a random one.
    pub fn seed() -> crypto::Seed {
        if let Ok(seed) = var(RAD_KEYGEN_SEED) {
            let Ok(seed) = (0..seed.len())