Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93c32b60ba | ||
|
|
0f4ba71219 | ||
|
|
e7cb136c38 |
Generated
+1
-1
@@ -21,7 +21,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-manager"
|
||||
version = "0.2.9"
|
||||
version = "0.2.12"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-manager"
|
||||
version = "0.2.9"
|
||||
version = "0.2.12"
|
||||
edition = "2021"
|
||||
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
||||
license = "MIT"
|
||||
@@ -36,7 +36,7 @@ which = "7"
|
||||
zip = "0.6"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.52", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_ProcessStatus", "Win32_System_Diagnostics_ToolHelp"] }
|
||||
windows-sys = { version = "0.52", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_ProcessStatus", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Console"] }
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
|
||||
+77
-50
@@ -51,9 +51,8 @@ pub fn dir_size(path: &Path) -> Option<u64> {
|
||||
Some(total)
|
||||
}
|
||||
|
||||
fn modified_str(path: &Path) -> String {
|
||||
std::fs::metadata(path)
|
||||
.and_then(|m| m.modified())
|
||||
fn modified_text(meta: &std::fs::Metadata) -> String {
|
||||
meta.modified()
|
||||
.ok()
|
||||
.map(|t| {
|
||||
let dt: chrono::DateTime<chrono::Local> = t.into();
|
||||
@@ -94,59 +93,86 @@ fn is_executable(path: &Path, meta: &std::fs::Metadata) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// One ls row: (sort key, cells). A single symlink_metadata call per entry
|
||||
/// (the modification time comes from the same metadata).
|
||||
fn row_for(p: &Path, color: bool) -> Option<(String, Vec<Cell>)> {
|
||||
let meta = std::fs::symlink_metadata(p).ok()?;
|
||||
let file_type = meta.file_type();
|
||||
let name = p.file_name().and_then(|n| n.to_str())?.to_string();
|
||||
let (kind, style) = if file_type.is_symlink() {
|
||||
("symlink", if p.exists() { 3 } else { 4 })
|
||||
} else if file_type.is_dir() {
|
||||
("dir", 1)
|
||||
} else if is_executable(p, &meta) {
|
||||
("file", 2)
|
||||
} else {
|
||||
("file", 0)
|
||||
};
|
||||
let (size_text, size_bytes) = if file_type.is_dir() {
|
||||
match dir_size(p) {
|
||||
Some(s) => (format_size(s), Some(s)),
|
||||
None => ("—".to_string(), None),
|
||||
}
|
||||
} else {
|
||||
(format_size(meta.len()), Some(meta.len()))
|
||||
};
|
||||
let mut display = name.clone();
|
||||
if file_type.is_dir() {
|
||||
display.push('/');
|
||||
} else if file_type.is_symlink() {
|
||||
if let Ok(target) = std::fs::read_link(p) {
|
||||
display.push_str(&format!(" -> {}", target.display()));
|
||||
}
|
||||
}
|
||||
Some((
|
||||
name.to_lowercase(),
|
||||
vec![
|
||||
Cell::str(paint(&display, color, style)),
|
||||
Cell::str(kind.to_string()),
|
||||
Cell::bytes(size_bytes.unwrap_or(0), size_text),
|
||||
Cell::str(modified_text(&meta)),
|
||||
],
|
||||
))
|
||||
}
|
||||
|
||||
/// Build the ls table (columns: name, type, size, modified). Directories
|
||||
/// come first, then alphabetical order.
|
||||
///
|
||||
/// Rows are built in parallel: recursive directory sizes dominate the cost,
|
||||
/// so a listing with many subdirectories stays fast (like Nushell, which
|
||||
/// also parallelizes its listing).
|
||||
pub fn ls_table(path: &Path, color: bool) -> Result<DataTable> {
|
||||
let rd = std::fs::read_dir(path)
|
||||
.with_context(|| format!("cannot read {}", path.display()))?;
|
||||
let mut rows: Vec<(String, Vec<Cell>)> = Vec::new();
|
||||
for entry in rd.flatten() {
|
||||
let p = entry.path();
|
||||
let meta = match std::fs::symlink_metadata(&p) {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let file_type = meta.file_type();
|
||||
let name = p
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let (kind, style) = if file_type.is_symlink() {
|
||||
("symlink", if p.exists() { 3 } else { 4 })
|
||||
} else if file_type.is_dir() {
|
||||
("dir", 1)
|
||||
} else if is_executable(&p, &meta) {
|
||||
("file", 2)
|
||||
} else {
|
||||
("file", 0)
|
||||
};
|
||||
let (size_text, size_bytes) = if file_type.is_dir() {
|
||||
match dir_size(&p) {
|
||||
Some(s) => (format_size(s), Some(s)),
|
||||
None => ("—".to_string(), None),
|
||||
}
|
||||
} else {
|
||||
(format_size(meta.len()), Some(meta.len()))
|
||||
};
|
||||
let mut display = name.clone();
|
||||
if file_type.is_dir() {
|
||||
display.push('/');
|
||||
} else if file_type.is_symlink() {
|
||||
if let Ok(target) = std::fs::read_link(&p) {
|
||||
display.push_str(&format!(" -> {}", target.display()));
|
||||
let entries: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
|
||||
|
||||
let threads = std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(4)
|
||||
.min(8);
|
||||
let chunk_size = entries.len().div_ceil(threads).max(1);
|
||||
let rows: Vec<(String, Vec<Cell>)> = std::thread::scope(|scope| {
|
||||
let mut handles = Vec::new();
|
||||
for chunk in entries.chunks(chunk_size) {
|
||||
let chunk: Vec<PathBuf> = chunk.to_vec();
|
||||
handles.push(scope.spawn(move || {
|
||||
chunk
|
||||
.into_iter()
|
||||
.filter_map(|p| row_for(&p, color))
|
||||
.collect::<Vec<_>>()
|
||||
}));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for handle in handles {
|
||||
if let Ok(mut rows) = handle.join() {
|
||||
out.append(&mut rows);
|
||||
}
|
||||
}
|
||||
rows.push((
|
||||
name.to_lowercase(),
|
||||
vec![
|
||||
Cell::str(paint(&display, color, style)),
|
||||
Cell::str(kind.to_string()),
|
||||
Cell::bytes(size_bytes.unwrap_or(0), size_text),
|
||||
Cell::str(modified_str(&p)),
|
||||
],
|
||||
));
|
||||
}
|
||||
out
|
||||
});
|
||||
|
||||
// Directories first, then alphabetical.
|
||||
let mut rows = rows;
|
||||
rows.sort_by(|a, b| {
|
||||
let a_dir = a.1[1].text == "dir";
|
||||
let b_dir = b.1[1].text == "dir";
|
||||
@@ -163,9 +189,10 @@ pub fn ls_table(path: &Path, color: bool) -> Result<DataTable> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Render a Nushell-style boxed table of the entries of a path.
|
||||
/// Render a Nushell-style boxed table of the entries of a path (unbounded
|
||||
/// width — used by tests; the REPL renders with the terminal width).
|
||||
pub fn render_ls(path: &Path, color: bool) -> Result<String> {
|
||||
Ok(ls_table(path, color)?.render(color))
|
||||
Ok(ls_table(path, color)?.render(color, 200))
|
||||
}
|
||||
|
||||
/// Completion candidates for a path fragment typed after cd or ls.
|
||||
|
||||
@@ -239,6 +239,42 @@ pub fn print_json<T: Serialize>(value: &T) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Width of the terminal attached to stdout, in columns. Returns None when
|
||||
/// stdout is not a terminal (e.g. piped). AM_TABLE_WIDTH overrides it, mostly
|
||||
/// for tests and scripts.
|
||||
pub fn terminal_width() -> Option<usize> {
|
||||
if let Ok(v) = std::env::var("AM_TABLE_WIDTH") {
|
||||
if let Ok(w) = v.trim().parse::<usize>() {
|
||||
return Some(w);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use windows_sys::Win32::System::Console::{
|
||||
GetConsoleScreenBufferInfo, CONSOLE_SCREEN_BUFFER_INFO,
|
||||
};
|
||||
let handle = std::io::stdout().as_raw_handle() as isize;
|
||||
let mut info: CONSOLE_SCREEN_BUFFER_INFO = unsafe { std::mem::zeroed() };
|
||||
if unsafe { GetConsoleScreenBufferInfo(handle, &mut info) } != 0 {
|
||||
let width = (info.srWindow.Right - info.srWindow.Left + 1) as usize;
|
||||
if width > 0 {
|
||||
return Some(width);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
if let Ok(v) = std::env::var("COLUMNS") {
|
||||
if let Ok(w) = v.trim().parse::<usize>() {
|
||||
return Some(w);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_ansi(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut chars = s.chars();
|
||||
|
||||
+53
-9
@@ -354,6 +354,7 @@ pub fn banner_box(
|
||||
version: &str,
|
||||
agents: usize,
|
||||
installed: usize,
|
||||
external: usize,
|
||||
session: &ShellSession,
|
||||
session_id: &str,
|
||||
cwd: &str,
|
||||
@@ -425,7 +426,7 @@ pub fn banner_box(
|
||||
rows.push(blank());
|
||||
rows.push(inner(dim(
|
||||
&format!(
|
||||
"am v{version} · {agents} agents · {installed} installed · shell {} · /help for commands",
|
||||
"am v{version} · {agents} agents · {external} external · {installed} managed · shell {} · /help for commands",
|
||||
session.current.name
|
||||
),
|
||||
color,
|
||||
@@ -449,6 +450,7 @@ pub fn banner(app: &App, session: &ShellSession, session_id: &str) -> String {
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
agent_count(app),
|
||||
installed_count(app),
|
||||
external_count(app),
|
||||
session,
|
||||
session_id,
|
||||
¤t_dir_string(),
|
||||
@@ -473,7 +475,9 @@ pub fn banner(app: &App, session: &ShellSession, session_id: &str) -> String {
|
||||
}
|
||||
|
||||
/// One-line status shown before every prompt (Hermes-style status bar).
|
||||
fn print_status(app: &App, session: &ShellSession, started: Instant) {
|
||||
/// The second parameter is the duration of the previous command (None before
|
||||
/// the first one).
|
||||
fn print_status(app: &App, session: &ShellSession, started: Instant, last: Option<Duration>) {
|
||||
let color = app.color();
|
||||
let sep = dim(" │ ", color);
|
||||
let cwd = shorten_head(¤t_dir_string(), 26);
|
||||
@@ -484,15 +488,23 @@ fn print_status(app: &App, session: &ShellSession, started: Instant) {
|
||||
);
|
||||
let counts = dim(
|
||||
&format!(
|
||||
"{} agents · {} installed",
|
||||
"{} agents · {} external · {} managed",
|
||||
agent_count(app),
|
||||
external_count(app),
|
||||
installed_count(app)
|
||||
),
|
||||
color,
|
||||
);
|
||||
let last_text = match last {
|
||||
Some(d) if d.as_secs() >= 1 => format_elapsed(d),
|
||||
Some(d) if d.as_millis() >= 1 => format!("{}ms", d.as_millis()),
|
||||
Some(d) => format!("{}µs", d.as_micros()),
|
||||
None => "—".to_string(),
|
||||
};
|
||||
let last_part = dim(&format!("⚡ {last_text}"), color);
|
||||
let elapsed = dim(&format_elapsed(started.elapsed()), color);
|
||||
println!(
|
||||
"{}am v{}{sep}{shell_part}{sep}{counts}{sep}⏲ {elapsed}",
|
||||
"{}am v{}{sep}{shell_part}{sep}{counts}{sep}{last_part}{sep}⏲ {elapsed}",
|
||||
dim(" ⚙ ", color),
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
);
|
||||
@@ -609,6 +621,25 @@ fn installed_count(app: &App) -> usize {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Number of external agents (installed outside agent-manager) detected on
|
||||
/// the PATH — the same definition used by the "external" status in am list.
|
||||
fn external_count(app: &App) -> usize {
|
||||
let probe = crate::probe::detect_externals(app);
|
||||
let managed: std::collections::BTreeSet<String> = app
|
||||
.state
|
||||
.load()
|
||||
.ok()
|
||||
.map(|s| s.installed.keys().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
app.catalog
|
||||
.agents()
|
||||
.iter()
|
||||
.filter(|a| !a.hidden && a.installable)
|
||||
.filter(|a| !managed.contains(&a.name))
|
||||
.filter(|a| probe.path_of(&a.name).is_some())
|
||||
.count()
|
||||
}
|
||||
|
||||
fn current_dir_string() -> String {
|
||||
std::env::current_dir()
|
||||
.map(|p| p.display().to_string())
|
||||
@@ -672,8 +703,9 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||
}
|
||||
let started = Instant::now();
|
||||
let mut last: Option<DataTable> = None;
|
||||
let mut last_duration: Option<Duration> = None;
|
||||
loop {
|
||||
print_status(app, session, started);
|
||||
print_status(app, session, started, last_duration);
|
||||
match rl.readline("❯ ") {
|
||||
Ok(line) => {
|
||||
let trimmed = line.trim().to_string();
|
||||
@@ -681,7 +713,10 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||
continue;
|
||||
}
|
||||
let _ = rl.add_history_entry(trimmed.as_str());
|
||||
match handle_input(app, session, &mut last, &trimmed) {
|
||||
let t = Instant::now();
|
||||
let result = handle_input(app, session, &mut last, &trimmed);
|
||||
last_duration = Some(t.elapsed());
|
||||
match result {
|
||||
Ok(true) => break,
|
||||
Ok(false) => {}
|
||||
Err(e) => app.log.error(&format!("{e:#}")),
|
||||
@@ -703,8 +738,9 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||
fn run_plain(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||
let started = Instant::now();
|
||||
let mut last: Option<DataTable> = None;
|
||||
let mut last_duration: Option<Duration> = None;
|
||||
loop {
|
||||
print_status(app, session, started);
|
||||
print_status(app, session, started, last_duration);
|
||||
eprint!("❯ ");
|
||||
std::io::stderr().flush().ok();
|
||||
let mut line = String::new();
|
||||
@@ -715,7 +751,10 @@ fn run_plain(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match handle_input(app, session, &mut last, &line) {
|
||||
let t = Instant::now();
|
||||
let result = handle_input(app, session, &mut last, &line);
|
||||
last_duration = Some(t.elapsed());
|
||||
match result {
|
||||
Ok(true) => break,
|
||||
Ok(false) => {}
|
||||
Err(e) => app.log.error(&format!("{e:#}")),
|
||||
@@ -982,8 +1021,10 @@ fn ls_target(rest: &[String]) -> Result<std::path::PathBuf> {
|
||||
}
|
||||
|
||||
/// Print a table and remember it as the last result (for where/get).
|
||||
/// The table is rendered to fit the terminal width so rows never wrap.
|
||||
fn show_table(app: &App, last: &mut Option<DataTable>, table: DataTable) {
|
||||
print!("{}", table.render(app.color()));
|
||||
let width = crate::output::terminal_width().unwrap_or(120);
|
||||
print!("{}", table.render(app.color(), width));
|
||||
*last = Some(table);
|
||||
}
|
||||
|
||||
@@ -1243,6 +1284,7 @@ mod tests {
|
||||
"9.9.9",
|
||||
65,
|
||||
3,
|
||||
12,
|
||||
&session,
|
||||
"20260815_123456_abcdef",
|
||||
"/home/alice/dev/agent-manager",
|
||||
@@ -1256,6 +1298,8 @@ mod tests {
|
||||
assert!(text.contains("(default)"));
|
||||
assert!(text.contains("bash"));
|
||||
assert!(text.contains("/help for commands"));
|
||||
assert!(text.contains("12 external"));
|
||||
assert!(text.contains("3 managed"));
|
||||
assert!(text.contains("agent-manager"));
|
||||
}
|
||||
|
||||
|
||||
+114
-17
@@ -53,12 +53,19 @@ impl DataTable {
|
||||
.position(|c| c.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// Render the boxed Nushell-style table.
|
||||
pub fn render(&self, color: bool) -> String {
|
||||
/// Render the boxed Nushell-style table, fitting max_width columns.
|
||||
/// When the natural layout is too wide, the first (name) column is
|
||||
/// truncated with an ellipsis and the trailing columns are dropped if
|
||||
/// that is still not enough — every row stays on a single line.
|
||||
pub fn render(&self, color: bool, max_width: usize) -> String {
|
||||
let max_width = max_width.max(20);
|
||||
let headers = self.columns.clone();
|
||||
if headers.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
// Column 0 is the row index; the data columns follow.
|
||||
let index_width = self.rows.len().to_string().len().max(1);
|
||||
let mut widths: Vec<usize> = headers.iter().map(|h| visible_len(h)).collect();
|
||||
let mut natural: Vec<usize> = headers.iter().map(|h| visible_len(h)).collect();
|
||||
let mut cells: Vec<Vec<String>> = Vec::new();
|
||||
for (i, row) in self.rows.iter().enumerate() {
|
||||
let mut line = vec![i.to_string()];
|
||||
@@ -66,25 +73,54 @@ impl DataTable {
|
||||
line.push(cell.text.clone());
|
||||
}
|
||||
for (c, cell) in line.iter().enumerate().skip(1) {
|
||||
widths[c - 1] = widths[c - 1].max(visible_len(cell));
|
||||
natural[c - 1] = natural[c - 1].max(visible_len(cell));
|
||||
}
|
||||
cells.push(line);
|
||||
}
|
||||
let line_width = |ncols: usize, widths: &[usize]| -> usize {
|
||||
1 + 3 * (ncols + 1) + index_width + widths.iter().sum::<usize>()
|
||||
};
|
||||
|
||||
// Choose the final column set and widths.
|
||||
let mut data_cols = headers.len();
|
||||
let mut widths: Vec<usize> = natural.clone();
|
||||
loop {
|
||||
if data_cols == 0 {
|
||||
break;
|
||||
}
|
||||
widths.truncate(data_cols);
|
||||
if line_width(data_cols, &widths) <= max_width {
|
||||
// Expand the first column into the remaining slack.
|
||||
widths[0] += max_width - line_width(data_cols, &widths);
|
||||
break;
|
||||
}
|
||||
// Cap the non-first columns, then give the first what remains.
|
||||
let mut capped = widths.clone();
|
||||
for w in capped.iter_mut().skip(1) {
|
||||
*w = (*w).min(16);
|
||||
}
|
||||
if line_width(data_cols, &capped) <= max_width {
|
||||
capped[0] += max_width - line_width(data_cols, &capped);
|
||||
widths = capped;
|
||||
break;
|
||||
}
|
||||
if data_cols == 1 {
|
||||
// Last resort: shrink the single column to whatever remains
|
||||
// after the borders and the index column.
|
||||
let fixed = 1 + 3 * (data_cols + 1) + index_width;
|
||||
capped[0] = max_width.saturating_sub(fixed).max(1);
|
||||
widths = capped;
|
||||
break;
|
||||
}
|
||||
data_cols -= 1;
|
||||
}
|
||||
|
||||
let all_headers: Vec<String> = std::iter::once("#".to_string())
|
||||
.chain(headers.iter().cloned())
|
||||
.chain(headers.iter().take(data_cols).cloned())
|
||||
.collect();
|
||||
let mut all_widths = vec![index_width];
|
||||
all_widths.extend(widths.iter().cloned());
|
||||
for (c, w) in all_widths.iter_mut().enumerate() {
|
||||
if c < all_headers.len() {
|
||||
*w = (*w).max(visible_len(&all_headers[c]));
|
||||
}
|
||||
}
|
||||
|
||||
let pad = |s: &str, w: usize| -> String {
|
||||
let n = visible_len(s);
|
||||
format!("{s}{}", " ".repeat(w.saturating_sub(n)))
|
||||
};
|
||||
let border = |left: char, mid: char, right: char| -> String {
|
||||
let mut line = String::new();
|
||||
line.push(left);
|
||||
@@ -99,9 +135,9 @@ impl DataTable {
|
||||
};
|
||||
let row_line = |row: &[String], bold: bool| -> String {
|
||||
let mut line = String::from("│");
|
||||
for (i, c) in row.iter().enumerate() {
|
||||
for (i, c) in row.iter().enumerate().take(all_widths.len()) {
|
||||
line.push(' ');
|
||||
let cell = pad(c, all_widths[i]);
|
||||
let cell = fit_cell(c, all_widths[i]);
|
||||
let rendered = if bold {
|
||||
cell.bold().to_string()
|
||||
} else {
|
||||
@@ -231,6 +267,33 @@ pub fn get_column(table: &DataTable, field: &str) -> Result<DataTable> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Pad a cell to its column width, or truncate it (with an ellipsis,
|
||||
/// preserving any leading color codes) when it is too wide.
|
||||
fn fit_cell(s: &str, w: usize) -> String {
|
||||
let n = visible_len(s);
|
||||
if n <= w {
|
||||
return format!("{s}{}", " ".repeat(w - n));
|
||||
}
|
||||
let plain = strip_ansi(s);
|
||||
let mut cut: String = plain.chars().take(w.saturating_sub(1).max(1)).collect();
|
||||
cut.push('…');
|
||||
if s.contains('') {
|
||||
let mut prefix = String::new();
|
||||
let mut chars = s.chars().peekable();
|
||||
while chars.peek() == Some(&'') {
|
||||
for ch in chars.by_ref() {
|
||||
prefix.push(ch);
|
||||
if ch == 'm' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
format!("{prefix}{cut}[0m")
|
||||
} else {
|
||||
cut
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_ansi(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut chars = s.chars();
|
||||
@@ -318,11 +381,45 @@ mod tests {
|
||||
#[test]
|
||||
fn render_is_aligned() {
|
||||
let t = sample();
|
||||
let text = t.render(false);
|
||||
let text = t.render(false, 200);
|
||||
assert!(text.contains("╭"));
|
||||
assert!(text.contains("name"));
|
||||
assert!(text.contains("am.exe"));
|
||||
let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
|
||||
assert!(widths.iter().all(|w| *w == widths[0]), "unaligned:\n{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_fits_narrow_width() {
|
||||
// A table whose first column is too long to fit naturally.
|
||||
let t = DataTable {
|
||||
columns: vec!["name".into(), "type".into(), "size".into()],
|
||||
rows: vec![vec![
|
||||
Cell::str("a-very-long-file-name-that-overflows.txt"),
|
||||
Cell::str("file"),
|
||||
Cell::bytes(512, "512 B".into()),
|
||||
]],
|
||||
};
|
||||
// Trailing columns are dropped when too narrow.
|
||||
let text = t.render(false, 45);
|
||||
for line in text.lines() {
|
||||
assert!(line.chars().count() <= 45, "too wide: {line}");
|
||||
}
|
||||
assert!(!text.contains("size"));
|
||||
// The name column is truncated with an ellipsis at the very end.
|
||||
let text = t.render(false, 30);
|
||||
for line in text.lines() {
|
||||
assert!(line.chars().count() <= 30, "too wide: {line}");
|
||||
}
|
||||
assert!(text.contains("…"), "name should be truncated:
|
||||
{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_cell_truncates_and_keeps_color() {
|
||||
let colored = "\u{1b}[34mverylongname\u{1b}[0m".to_string();
|
||||
let out = fit_cell(&colored, 8);
|
||||
assert_eq!(strip_ansi(&out).chars().count(), 8);
|
||||
assert!(out.starts_with("\u{1b}["));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user