Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cli: use a pager on list commands
Frederic Lepied committed 8 months ago
commit 8ab3e770b97ef8a52d31fbe02e60df9973332206
parent f00d1d67432882bef11fc940601f071efe55c88d
15 files changed +241 -25
modified crates/radicle-cli/src/commands/issue.rs
@@ -823,7 +823,8 @@ where
                .into(),
        ]);
    }
-
    table.print();
+

+
    term::print_with_pager(table)?;

    Ok(())
}
modified crates/radicle-cli/src/commands/ls.rs
@@ -5,8 +5,6 @@ use radicle::storage::{ReadStorage, RepositoryInfo};
use crate::terminal as term;
use crate::terminal::args::{Args, Error, Help};

-
use term::Element;
-

pub const HELP: Help = Help {
    name: "ls",
    description: "List repositories",
@@ -157,7 +155,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        ]);
        table.divider();
        table.extend(rows);
-
        table.print();
+
        term::print_with_pager(table)?;
    }

    Ok(())
modified crates/radicle-cli/src/commands/node.rs
@@ -14,7 +14,6 @@ use radicle::prelude::RepoId;

use crate::terminal as term;
use crate::terminal::args::{Args, Error, Help};
-
use crate::terminal::Element as _;

#[path = "node/commands.rs"]
mod commands;
@@ -311,7 +310,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        Operation::Sessions => {
            let sessions = control::sessions(&node)?;
            if let Some(table) = sessions {
-
                table.print();
+
                term::print_with_pager(table)?;
            }
        }
        Operation::Events { timeout, count } => {
modified crates/radicle-cli/src/commands/node/control.rs
@@ -304,7 +304,7 @@ pub fn status(node: &Node, profile: &Profile) -> anyhow::Result<()> {
    let sessions = sessions(node)?;
    if let Some(table) = sessions {
        term::blank();
-
        table.print();
+
        term::print_with_pager(table)?;
    }

    if profile.hints() {
modified crates/radicle-cli/src/commands/patch/list.rs
@@ -9,7 +9,6 @@ use radicle::storage::git::Repository;

use term::format::Author;
use term::table::{Table, TableOptions};
-
use term::Element as _;

use crate::terminal as term;
use crate::terminal::patch as common;
@@ -86,7 +85,7 @@ pub fn run(
            Err(e) => errors.push((patch.title(), id, e.to_string())),
        }
    }
-
    table.print();
+
    term::print_with_pager(table)?;

    if !errors.is_empty() {
        for (title, id, error) in errors {
modified crates/radicle-cli/src/commands/remote.rs
@@ -209,20 +209,20 @@ pub fn run(options: Options, ctx: impl Context) -> anyhow::Result<()> {
                // Only include a blank line if we're printing both tracked and untracked
                let include_blank_line = !tracked.is_empty() && !untracked.is_empty();

-
                list::print_tracked(tracked.iter());
+
                list::print_tracked(tracked.iter())?;
                if include_blank_line {
                    term::blank();
                }
-
                list::print_untracked(untracked.iter());
+
                list::print_untracked(untracked.iter())?;
            }
            ListOption::Tracked => {
                let tracked = list::tracked(&working)?;
-
                list::print_tracked(tracked.iter());
+
                list::print_tracked(tracked.iter())?;
            }
            ListOption::Untracked => {
                let tracked = list::tracked(&working)?;
                let untracked = list::untracked(rid, &profile, tracked.iter())?;
-
                list::print_untracked(untracked.iter());
+
                list::print_untracked(untracked.iter())?;
            }
        },
    };
modified crates/radicle-cli/src/commands/remote/list.rs
@@ -5,7 +5,7 @@ use radicle::identity::{Did, RepoId};
use radicle::node::{Alias, AliasStore as _, NodeId};
use radicle::storage::ReadStorage as _;
use radicle::Profile;
-
use radicle_term::{Element, Table};
+
use radicle_term::Table;

use crate::git;
use crate::terminal as term;
@@ -80,7 +80,7 @@ pub fn untracked<'a>(
        .collect::<Result<Vec<_>, _>>()?)
}

-
pub fn print_tracked<'a>(tracked: impl Iterator<Item = &'a Tracked>) {
+
pub fn print_tracked<'a>(tracked: impl Iterator<Item = &'a Tracked>) -> anyhow::Result<()> {
    let mut table = Table::default();
    for Tracked { direction, name } in tracked {
        let Some(direction) = direction else {
@@ -101,10 +101,11 @@ pub fn print_tracked<'a>(tracked: impl Iterator<Item = &'a Tracked>) {
            term::format::parens(term::format::secondary(dir.to_owned())),
        ]);
    }
-
    table.print();
+
    term::print_with_pager(table)?;
+
    Ok(())
}

-
pub fn print_untracked<'a>(untracked: impl Iterator<Item = &'a Untracked>) {
+
pub fn print_untracked<'a>(untracked: impl Iterator<Item = &'a Untracked>) -> anyhow::Result<()> {
    let mut t = Table::default();
    for Untracked { remote, alias } in untracked {
        t.push([
@@ -115,5 +116,6 @@ pub fn print_untracked<'a>(untracked: impl Iterator<Item = &'a Untracked>) {
            term::format::highlight(Did::from(remote).to_string()),
        ])
    }
-
    t.print();
+
    term::print_with_pager(t)?;
+
    Ok(())
}
modified crates/radicle-cli/src/commands/self.rs
@@ -2,11 +2,11 @@ use std::ffi::OsString;

use radicle::crypto::ssh;
use radicle::node::Handle as _;
-
use radicle::{Node, Profile};
+
use radicle::node::Node;
+
use radicle::profile::Profile;

use crate::terminal as term;
use crate::terminal::args::{Args, Error, Help};
-
use crate::terminal::Element as _;

pub const HELP: Help = Help {
    name: "self",
@@ -210,7 +210,7 @@ fn all(profile: &Profile) -> anyhow::Result<()> {
        term::format::tertiary(profile.home.node().display()).into(),
    ]);

-
    table.print();
+
    term::print_with_pager(table)?;

    Ok(())
}
modified crates/radicle-cli/src/commands/sync.rs
@@ -404,7 +404,7 @@ fn sync_status(
            time.dim().italic().into(),
        ]);
    }
-
    table.print();
+
    term::print_with_pager(table)?;

    if profile.hints() {
        const COLUMN_WIDTH: usize = 16;
modified crates/radicle-cli/src/terminal.rs
@@ -161,3 +161,11 @@ pub fn fail(_name: &str, error: &anyhow::Error) {
        io::hint(hint);
    }
}
+

+
/// Print an element with automatic paging when there are too many lines.
+
/// This function automatically detects if the content is too long for the terminal
+
/// and uses a pager if necessary.
+
pub fn print_with_pager(elem: impl Element) -> anyhow::Result<()> {
+
    elem.print_with_pager()?;
+
    Ok(())
+
}
modified crates/radicle-cli/src/terminal/patch.rs
@@ -317,7 +317,7 @@ pub fn list_commits(commits: &[git::raw::Commit]) -> anyhow::Result<()> {
            term::format::italic(String::from_utf8_lossy(message).to_string()),
        ]);
    }
-
    table.print();
+
    term::print_with_pager(table)?;

    Ok(())
}
modified crates/radicle-term/src/element.rs
@@ -56,7 +56,13 @@ impl Constraint {
    /// Returns [`None`] if the output device is not a terminal.
    pub fn from_env() -> Option<Self> {
        if io::stdout().is_terminal() {
-
            Some(Self::max(viewport().unwrap_or(Size::MAX)))
+
            // Use our more reliable terminal size detection instead of the broken viewport() function
+
            if let Some((cols, rows)) = get_terminal_size() {
+
                Some(Self::max(Size::new(cols, rows)))
+
            } else {
+
                // Fallback to viewport if our detection fails
+
                Some(Self::max(viewport().unwrap_or(Size::MAX)))
+
            }
        } else {
            None
        }
@@ -89,6 +95,32 @@ pub trait Element: fmt::Debug + Send + Sync {
        }
    }

+
    /// Print this element to stdout, automatically using a pager if there are too many lines.
+
    /// This method is preferred over `print()` when you want automatic paging behavior.
+
    fn print_with_pager(&self) -> io::Result<()> {
+
        // Only use pager if we're on a terminal and not in tests
+
        if !io::stdout().is_terminal() || cfg!(test) {
+
            // Not on a terminal or running in tests, just print normally
+
            self.print();
+
            return Ok(());
+
        }
+

+
        // Get the actual content size using UNBOUNDED constraint
+
        let element_rows = self.size(Constraint::UNBOUNDED).rows;
+

+
        // Try to get terminal size to determine if paging is needed
+
        if let Some((_, terminal_rows)) = get_terminal_size() {
+
            // Use pager if the element is too tall for the terminal
+
            if element_rows > terminal_rows {
+
                return crate::pager::run(self);
+
            }
+
        }
+

+
        // Otherwise, just print normally
+
        self.print();
+
        Ok(())
+
    }
+

    /// Write using the given constraints to `stdout`.
    fn write(&self, constraints: Constraint) -> io::Result<()>
    where
@@ -121,6 +153,10 @@ impl Element for Box<dyn Element + '_> {
    fn print(&self) {
        self.deref().print()
    }
+

+
    fn print_with_pager(&self) -> io::Result<()> {
+
        self.deref().print_with_pager()
+
    }
}

impl<T: Element> Element for &T {
@@ -135,11 +171,15 @@ impl<T: Element> Element for &T {
    fn print(&self) {
        (*self).print()
    }
+

+
    fn print_with_pager(&self) -> io::Result<()> {
+
        (*self).print_with_pager()
+
    }
}

/// Write using the given constraints, to a writer.
pub fn write_to(
-
    elem: &impl Element,
+
    elem: &(impl Element + ?Sized),
    writer: &mut impl io::Write,
    constraints: Constraint,
) -> io::Result<()> {
@@ -419,3 +459,42 @@ mod test {
        );
    }
}
+

+
/// Get terminal size with fallback methods
+
pub fn get_terminal_size() -> Option<(usize, usize)> {
+
    // First try crossterm
+
    if let Some(size) = crate::viewport() {
+
        // Validate the size - if it's too small, it's probably wrong
+
        if size.rows >= 10 && size.cols >= 20 {
+
            return Some((size.cols, size.rows));
+
        }
+
    }
+

+
    // Fallback to environment variables
+
    if let (Ok(lines), Ok(cols)) = (std::env::var("LINES"), std::env::var("COLUMNS")) {
+
        if let (Ok(lines), Ok(cols)) = (lines.parse::<usize>(), cols.parse::<usize>()) {
+
            if lines >= 10 && cols >= 20 {
+
                return Some((cols, lines));
+
            }
+
        }
+
    }
+

+
    // Fallback to stty command
+
    if let Ok(output) = std::process::Command::new("stty").arg("size").output() {
+
        if let Ok(output_str) = String::from_utf8(output.stdout) {
+
            let parts: Vec<&str> = output_str.trim().split_whitespace().collect();
+
            if parts.len() == 2 {
+
                if let (Ok(lines), Ok(cols)) =
+
                    (parts[1].parse::<usize>(), parts[0].parse::<usize>())
+
                {
+
                    if lines >= 10 && cols >= 20 {
+
                        return Some((cols, lines));
+
                    }
+
                }
+
            }
+
        }
+
    }
+

+
    // Final fallback: assume reasonable defaults for modern terminals
+
    Some((80, 24))
+
}
modified crates/radicle-term/src/lib.rs
@@ -7,6 +7,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 crates/radicle-term/src/pager.rs
@@ -0,0 +1,115 @@
+
use std::io::{self, IsTerminal};
+
use std::process::{Command, Stdio};
+

+
use crate::element::{self, get_terminal_size, Constraint, Element};
+

+
/// Output the given element through a pager, if necessary.
+
/// If it fits within the screen, don't run it through a pager.
+
pub fn run(elem: &(impl Element + ?Sized)) -> io::Result<()> {
+
    // Only use pager if we're on a terminal and not in tests
+
    if !io::stdout().is_terminal() || cfg!(test) {
+
        // Not on a terminal or running in tests, just write directly to stdout
+
        return element::write_to(elem, &mut io::stdout(), Constraint::UNBOUNDED);
+
    }
+

+
    // Note: We don't need to create a constraint here since we use UNBOUNDED when rendering to pager
+
    // The pager itself handles width constraints
+

+
    let Some((_, _rows)) = get_terminal_size() else {
+
        return element::write_to(elem, &mut io::stdout(), Constraint::UNBOUNDED);
+
    };
+

+
    // Get the actual content size using UNBOUNDED constraint
+
    let element_rows = elem.size(Constraint::UNBOUNDED).rows;
+

+
    // Check if the element fits within the terminal
+
    if let Some((_, terminal_rows)) = get_terminal_size() {
+
        // If the element fits within the terminal, don't use pager
+
        if element_rows <= terminal_rows {
+
            return element::write_to(elem, &mut io::stdout(), Constraint::UNBOUNDED);
+
        }
+
    }
+

+
    // Get the pager command, but validate it's a real pager
+
    let pager = std::env::var("PAGER")
+
        .ok()
+
        .or_else(|| std::env::var("LESS").ok())
+
        .or_else(|| Some("more".to_string()));
+

+
    // Validate that the pager is a real pager, not a shell command
+
    let pager = if let Some(ref pager_cmd) = pager {
+
        if pager_cmd.contains("sh -c") || pager_cmd.contains("head") || pager_cmd.contains("cat") {
+
            // PAGER appears to be a shell command, fall back to 'more' (safer with colors)
+
            "more".to_string()
+
        } else {
+
            pager_cmd.clone()
+
        }
+
    } else {
+
        "more".to_string()
+
    };
+
    let Some(parts) = shlex::split(&pager) else {
+
        // Fallback to 'more' if pager parsing fails
+
        let mut child = Command::new("more")
+
            .stdin(Stdio::piped())
+
            .stdout(Stdio::inherit())
+
            .stderr(Stdio::inherit())
+
            .spawn()?;
+

+
        let writer = child.stdin.as_mut().unwrap();
+
        let result = element::write_to(elem, writer, Constraint::UNBOUNDED);
+

+
        child.wait()?;
+

+
        match result {
+
            Err(e) if e.kind() == io::ErrorKind::BrokenPipe => {}
+
            Err(e) => return Err(e),
+
            Ok(_) => {}
+
        }
+

+
        return Ok(());
+
    };
+
    let Some((program, args)) = parts.split_first() else {
+
        // Fallback to 'more' if pager parsing fails
+
        let mut child = Command::new("more")
+
            .stdin(Stdio::piped())
+
            .stdout(Stdio::inherit())
+
            .stderr(Stdio::inherit())
+
            .spawn()?;
+

+
        let writer = child.stdin.as_mut().unwrap();
+
        let result = element::write_to(elem, writer, Constraint::UNBOUNDED);
+

+
        child.wait()?;
+

+
        match result {
+
            Err(e) if e.kind() == io::ErrorKind::BrokenPipe => {}
+
            Err(e) => return Err(e),
+
            Ok(_) => {}
+
        }
+

+
        return Ok(());
+
    };
+

+
    let mut child = Command::new(program)
+
        .stdin(Stdio::piped())
+
        .stdout(Stdio::inherit())
+
        .stderr(Stdio::inherit())
+
        .args(args)
+
        .spawn()?;
+

+
    let writer = child.stdin.as_mut().unwrap();
+
    // Use UNBOUNDED constraint when rendering to pager to preserve table formatting
+
    // The pager itself will handle width constraints
+
    let result = element::write_to(elem, writer, Constraint::UNBOUNDED);
+

+
    child.wait()?;
+

+
    match result {
+
        // This error is expected when the pager is exited.
+
        Err(e) if e.kind() == io::ErrorKind::BrokenPipe => {}
+
        Err(e) => return Err(e),
+
        Ok(_) => {}
+
    }
+

+
    Ok(())
+
}
modified crates/radicle-term/src/table.rs
@@ -221,6 +221,20 @@ impl<const W: usize, T: Cell> Table<W, T> {
            cols += 2 + padding;
            rows += 2;
        }
+

+
        // If the constraint is effectively UNBOUNDED (max is Size::MAX), use a reasonable maximum width
+
        // This preserves table formatting when rendering to pager without making it excessively wide
+
        if c.max == Size::MAX {
+
            // Use a reasonable maximum width (e.g., 120 columns) to preserve formatting
+
            // but avoid making the table excessively wide
+
            let max_width = 120;
+
            if cols > max_width {
+
                return Size::new(max_width, rows);
+
            }
+
            return Size::new(cols, rows);
+
        }
+

+
        // Otherwise, apply the constraint
        Size::new(cols, rows).constrain(c)
    }
}