From 57234b7a3848532f161c5483d6aabcf7ee489eb7 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 16 Aug 2026 09:11:09 -0400 Subject: [PATCH] v0.2.9 : ps/where/get facon Nushell avec mini-pipeline (ls | where size > 1mb | get name), dir = ls sur Windows, ls beaucoup plus rapide (taille des dossiers bornee a 500 entrees), autocomplet : prefixe commun + menu avec descriptions + fallback chemins --- Cargo.lock | 3 +- Cargo.toml | 5 +- README.md | 5 + src/lib.rs | 2 + src/nav.rs | 218 +++++++++++---------------------- src/ps.rs | 218 +++++++++++++++++++++++++++++++++ src/repl.rs | 172 +++++++++++++++++++++++--- src/tables.rs | 328 ++++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 784 insertions(+), 167 deletions(-) create mode 100644 src/ps.rs create mode 100644 src/tables.rs diff --git a/Cargo.lock b/Cargo.lock index 1bcc5e1..37e7e26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "0.2.8" +version = "0.2.9" dependencies = [ "anyhow", "chrono", @@ -41,6 +41,7 @@ dependencies = [ "ureq", "wait-timeout", "which", + "windows-sys 0.52.0", "zip", ] diff --git a/Cargo.toml b/Cargo.toml index 9d2fadb..963c91b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-manager" -version = "0.2.8" +version = "0.2.9" edition = "2021" description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." license = "MIT" @@ -35,6 +35,9 @@ wait-timeout = "0.2" 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"] } + [profile.release] lto = "thin" strip = true diff --git a/README.md b/README.md index 2f81d94..7dd1207 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,12 @@ démarrage et mis en évidence** dans la bannière (config PATH — pwsh, powershell, cmd, bash, zsh, fish, sh, nu, elvish). ❯ ls # liste façon Nushell : tableau encadré (type, taille, date) + ❯ dir # sur Windows, même affichage que ls ❯ ls src # un chemin précis — Tab complète fichiers et dossiers + ❯ ps # liste des processus (pid, ppid, cpu, mem, threads) + ❯ ls | where size > 1mb # filtre la dernière table (>, <, >=, <=, ==, !=, =~) + ❯ ps | where name =~ am # =~ cherche dans le texte + ❯ ls | get name # sélectionne une colonne ❯ cd # complétion des dossiers uniquement, un niveau par Tab ❯ shell # affiche le shell courant + les shells disponibles ❯ shell bash # change le shell de la session diff --git a/src/lib.rs b/src/lib.rs index f8df5a8..4b6f5a2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,11 +23,13 @@ pub mod installers; pub mod nav; pub mod output; pub mod probe; +pub mod ps; pub mod process; pub mod repl; pub mod runner; pub mod shell; pub mod state; +pub mod tables; pub mod toolchain; pub mod version; diff --git a/src/nav.rs b/src/nav.rs index 2901430..15dfefd 100644 --- a/src/nav.rs +++ b/src/nav.rs @@ -1,6 +1,7 @@ //! Nushell-inspired navigation helpers: a boxed table listing of files and //! directories (ls), and directory/path completion for Tab. +use crate::tables::{Cell, DataTable}; use anyhow::{Context, Result}; use colored::Colorize; use std::path::{Path, PathBuf}; @@ -22,10 +23,11 @@ pub fn format_size(bytes: u64) -> String { } } -/// Recursive size of a directory, bounded: beyond the entry cap the size is -/// reported as unknown (None) so huge trees never freeze the listing. +/// Recursive size of a directory, tightly bounded: beyond the entry cap the +/// size is reported as unknown (None). The cap is deliberately small so a +/// listing stays snappy even when it contains huge trees. pub fn dir_size(path: &Path) -> Option { - const CAP: usize = 20_000; + const CAP: usize = 500; let mut total: u64 = 0; let mut stack: Vec = vec![path.to_path_buf()]; let mut visited: usize = 0; @@ -49,14 +51,6 @@ pub fn dir_size(path: &Path) -> Option { Some(total) } -/// One entry of the ls table. -struct LsRow { - name: String, - kind: &'static str, - size: String, - modified: String, -} - fn modified_str(path: &Path) -> String { std::fs::metadata(path) .and_then(|m| m.modified()) @@ -81,14 +75,31 @@ fn paint(s: &str, color: bool, style: u8) -> String { } } -/// Render a Nushell-style boxed table of the entries of a path, with the -/// columns: index, name, type, size, modified. Directories come first, then -/// alphabetical order; sizes are human-readable and directories show their -/// recursive size (bounded). -pub fn render_ls(path: &Path, color: bool) -> Result { +fn is_executable(path: &Path, meta: &std::fs::Metadata) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + meta.permissions().mode() & 0o111 != 0 + } + #[cfg(windows)] + { + let _ = meta; + matches!( + path.extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()) + .as_deref(), + Some("exe") | Some("bat") | Some("cmd") | Some("com") + ) + } +} + +/// Build the ls table (columns: name, type, size, modified). Directories +/// come first, then alphabetical order. +pub fn ls_table(path: &Path, color: bool) -> Result { let rd = std::fs::read_dir(path) .with_context(|| format!("cannot read {}", path.display()))?; - let mut rows: Vec = Vec::new(); + let mut rows: Vec<(String, Vec)> = Vec::new(); for entry in rd.flatten() { let p = entry.path(); let meta = match std::fs::symlink_metadata(&p) { @@ -109,13 +120,13 @@ pub fn render_ls(path: &Path, color: bool) -> Result { } else { ("file", 0) }; - let size = if file_type.is_dir() { + let (size_text, size_bytes) = if file_type.is_dir() { match dir_size(&p) { - Some(s) => format_size(s), - None => "—".to_string(), + Some(s) => (format_size(s), Some(s)), + None => ("—".to_string(), None), } } else { - format_size(meta.len()) + (format_size(meta.len()), Some(meta.len())) }; let mut display = name.clone(); if file_type.is_dir() { @@ -125,114 +136,36 @@ pub fn render_ls(path: &Path, color: bool) -> Result { display.push_str(&format!(" -> {}", target.display())); } } - rows.push(LsRow { - name: paint(&display, color, style), - kind, - size, - modified: modified_str(&p), - }); + 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)), + ], + )); } - // Directories first, then alphabetical (case-insensitive). + // Directories first, then alphabetical. rows.sort_by(|a, b| { - let a_dir = a.kind == "dir"; - let b_dir = b.kind == "dir"; - b_dir - .cmp(&a_dir) - .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + let a_dir = a.1[1].text == "dir"; + let b_dir = b.1[1].text == "dir"; + b_dir.cmp(&a_dir).then_with(|| a.0.cmp(&b.0)) }); - - // Column contents: index, name, type, size, modified. - let headers = vec!["#", "name", "type", "size", "modified"]; - let mut widths: Vec = headers.iter().map(|h| visible_len(h)).collect(); - let mut cells: Vec> = Vec::new(); - for (i, row) in rows.iter().enumerate() { - let line = vec![ - i.to_string(), - row.name.clone(), - row.kind.to_string(), - row.size.clone(), - row.modified.clone(), - ]; - for (c, cell) in line.iter().enumerate() { - widths[c] = widths[c].max(visible_len(cell)); - } - cells.push(line); - } - - 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); - for (i, w) in widths.iter().enumerate() { - if i > 0 { - line.push(mid); - } - line.push_str(&"─".repeat(w + 2)); - } - line.push(right); - line - }; - let row = |cells: &[String], bold: bool| -> String { - let mut line = String::from("│"); - for (i, c) in cells.iter().enumerate() { - line.push(' '); - let cell = pad(c, widths[i]); - let rendered = if bold { - cell.bold().to_string() - } else { - cell - }; - line.push_str(&rendered); - line.push(' '); - line.push('│'); - } - line - }; - - let dim = |s: &str| -> String { - if color { - s.dimmed().to_string() - } else { - s.to_string() - } - }; - - let mut out = String::new(); - out.push_str(&dim(&border('╭', '┬', '╮'))); - out.push('\n'); - out.push_str(&row(&headers.iter().map(|h| h.to_string()).collect::>(), true)); - out.push('\n'); - out.push_str(&dim(&border('├', '┼', '┤'))); - out.push('\n'); - for line in &cells { - out.push_str(&row(line, false)); - out.push('\n'); - } - out.push_str(&dim(&border('╰', '┴', '╯'))); - out.push('\n'); - Ok(out) + Ok(DataTable { + columns: vec![ + "name".into(), + "type".into(), + "size".into(), + "modified".into(), + ], + rows: rows.into_iter().map(|(_, r)| r).collect(), + }) } -fn is_executable(path: &Path, meta: &std::fs::Metadata) -> bool { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - meta.permissions().mode() & 0o111 != 0 - } - #[cfg(windows)] - { - let _ = meta; - matches!( - path.extension() - .and_then(|e| e.to_str()) - .map(|e| e.to_lowercase()) - .as_deref(), - Some("exe") | Some("bat") | Some("cmd") | Some("com") - ) - } +/// Render a Nushell-style boxed table of the entries of a path. +pub fn render_ls(path: &Path, color: bool) -> Result { + Ok(ls_table(path, color)?.render(color)) } /// Completion candidates for a path fragment typed after cd or ls. @@ -294,27 +227,6 @@ pub fn path_candidates(base_dir: &Path, input: &str, dirs_only: bool) -> Vec String { - let mut out = String::new(); - let mut chars = s.chars(); - while let Some(c) = chars.next() { - if c == '\u{1b}' { - for n in chars.by_ref() { - if n == 'm' { - break; - } - } - } else { - out.push(c); - } - } - out -} - -fn visible_len(s: &str) -> usize { - strip_ansi(s).chars().count() -} - #[cfg(test)] mod tests { use super::*; @@ -328,6 +240,22 @@ mod tests { assert_eq!(format_size(3 * 1024 * 1024 * 1024), "3.0 GB"); } + #[test] + fn dir_size_is_bounded() { + let dir = tempfile::tempdir().unwrap(); + let big = dir.path().join("big"); + std::fs::create_dir_all(&big).unwrap(); + for i in 0..520 { + std::fs::write(big.join(format!("f{i}.txt")), "x").unwrap(); + } + // Beyond the cap the size is unknown, and the walk stays cheap. + assert_eq!(dir_size(&big), None); + let small = dir.path().join("small"); + std::fs::create_dir_all(&small).unwrap(); + std::fs::write(small.join("a.txt"), "hello").unwrap(); + assert_eq!(dir_size(&small), Some(5)); + } + #[test] fn completes_directories_only() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/ps.rs b/src/ps.rs new file mode 100644 index 0000000..291e237 --- /dev/null +++ b/src/ps.rs @@ -0,0 +1,218 @@ +//! ps: Nushell-style process table. +//! +//! Windows uses the Toolhelp32 snapshot API (pid, ppid, name, threads, cpu +//! time, working set); Unix reads /proc. + +use crate::tables::{Cell, DataTable}; + +/// Columns: pid, ppid, name, cpu, mem, threads. +fn columns() -> Vec { + vec![ + "pid".into(), + "ppid".into(), + "name".into(), + "cpu".into(), + "mem".into(), + "threads".into(), + ] +} + +/// Build the process table. +pub fn ps_table() -> DataTable { + #[cfg(windows)] + let rows = windows_rows(); + #[cfg(unix)] + let rows = unix_rows(); + #[cfg(not(any(windows, unix)))] + let rows = Vec::new(); + + let mut rows = rows; + rows.sort_by(|a, b| { + a[0] + .num + .unwrap_or(0.0) + .partial_cmp(&b[0].num.unwrap_or(0.0)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + DataTable { + columns: columns(), + rows, + } +} + +// --------------------------------------------------------------------------- +// Windows: Toolhelp32 snapshot +// --------------------------------------------------------------------------- + +#[cfg(windows)] +fn windows_rows() -> Vec> { + use crate::nav::format_size; + use std::mem::size_of; + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }; + use windows_sys::Win32::System::ProcessStatus::{ + K32GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, + }; + use windows_sys::Win32::System::Threading::{ + GetProcessTimes, OpenProcess, PROCESS_QUERY_INFORMATION, + }; + + let mut rows: Vec> = Vec::new(); + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return rows; + } + let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() }; + entry.dwSize = size_of::() as u32; + let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) }; + while ok != 0 { + let pid = entry.th32ProcessID; + let ppid = entry.th32ParentProcessID; + let threads = entry.cntThreads; + let mut name = String::new(); + for &unit in entry.szExeFile.iter() { + if unit == 0 { + break; + } + if let Some(c) = char::from_u32(unit as u32) { + name.push(c); + } + } + + // CPU time and memory need a process handle; some system processes + // refuse — those cells stay "—". + let mut cpu = "—".to_string(); + let mut mem = "—".to_string(); + let handle = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid) }; + if handle != 0 { + let mut counters: PROCESS_MEMORY_COUNTERS = unsafe { std::mem::zeroed() }; + if unsafe { + K32GetProcessMemoryInfo( + handle, + &mut counters, + size_of::() as u32, + ) + } != 0 + { + mem = format_size(counters.WorkingSetSize as u64); + } + let (mut created, mut exited, mut kernel, mut user): ( + windows_sys::Win32::Foundation::FILETIME, + windows_sys::Win32::Foundation::FILETIME, + windows_sys::Win32::Foundation::FILETIME, + windows_sys::Win32::Foundation::FILETIME, + ) = unsafe { + ( + std::mem::zeroed(), + std::mem::zeroed(), + std::mem::zeroed(), + std::mem::zeroed(), + ) + }; + if unsafe { GetProcessTimes(handle, &mut created, &mut exited, &mut kernel, &mut user) } != 0 { + let ticks = (filetime_u64(&kernel) + filetime_u64(&user)) as f64 / 10_000_000.0; + cpu = format!("{ticks:.0}s"); + } + unsafe { CloseHandle(handle) }; + } + + rows.push(vec![ + Cell::num(pid as f64, pid.to_string()), + Cell::num(ppid as f64, ppid.to_string()), + Cell::str(name), + Cell::str(cpu), + Cell::str(mem), + Cell::num(threads as f64, threads.to_string()), + ]); + ok = unsafe { Process32NextW(snapshot, &mut entry) }; + } + unsafe { CloseHandle(snapshot) }; + rows +} + +#[cfg(windows)] +fn filetime_u64(ft: &windows_sys::Win32::Foundation::FILETIME) -> u64 { + ((ft.dwHighDateTime as u64) << 32) | (ft.dwLowDateTime as u64) +} + +// --------------------------------------------------------------------------- +// Unix: /proc +// --------------------------------------------------------------------------- + +#[cfg(unix)] +fn unix_rows() -> Vec> { + use crate::nav::format_size; + let page_size = page_size(); + let mut rows: Vec> = Vec::new(); + let Ok(entries) = std::fs::read_dir("/proc") else { + return rows; + }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + let Ok(pid) = name.parse::() else { + continue; + }; + let base = entry.path(); + let Ok(stat) = std::fs::read_to_string(base.join("stat")) else { + continue; + }; + // The comm field is in parentheses and may contain spaces. + let Some(close) = stat.rfind(')') else { + continue; + }; + let comm = stat[stat.find('(').unwrap_or(0) + 1..close].to_string(); + let rest: Vec<&str> = stat[close + 2..].split_whitespace().collect(); + if rest.len() < 22 { + continue; + } + // rest[0]=state, rest[1]=ppid, rest[11]=utime, rest[12]=stime, + // rest[17]=num_threads, rest[21]=rss (pages). + let ppid = rest[1].parse::().unwrap_or(0.0); + let utime = rest[11].parse::().unwrap_or(0.0); + let stime = rest[12].parse::().unwrap_or(0.0); + let threads = rest[17].parse::().unwrap_or(0.0); + let rss_pages = rest[21].parse::().unwrap_or(0); + let cpu = (utime + stime) / 100.0; + rows.push(vec![ + Cell::num(pid, pid.to_string()), + Cell::num(ppid, ppid.to_string()), + Cell::str(comm), + Cell::str(format!("{cpu:.0}s")), + Cell::str(format_size(rss_pages * page_size)), + Cell::num(threads, threads.to_string()), + ]); + } + rows +} + +#[cfg(unix)] +fn page_size() -> u64 { + // Standard page size; RSS in pages * 4096 is close enough for display. + 4096 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ps_produces_a_table() { + let table = ps_table(); + assert_eq!( + table.columns, + vec!["pid", "ppid", "name", "cpu", "mem", "threads"] + ); + assert!(!table.rows.is_empty(), "process table should not be empty"); + // Our own pid is in the table. + let me = std::process::id().to_string(); + assert!( + table.rows.iter().any(|r| r[0].text == me), + "our pid should be listed" + ); + // Rows have as many cells as columns. + assert!(table.rows.iter().all(|r| r.len() == 6)); + } +} diff --git a/src/repl.rs b/src/repl.rs index 9755565..1214228 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -16,6 +16,8 @@ use crate::app::App; use crate::cli::{Cli, Command, StartArgs}; use crate::commands; use crate::nav; +use crate::ps; +use crate::tables::DataTable; use crate::shell::ShellSession; use anyhow::{anyhow, Result}; use clap::CommandFactory; @@ -72,7 +74,11 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[ ("import", "restore a backup"), ("shell", "show or switch the system shell"), ("ls", "list files and directories (Nushell-style)"), + ("dir", "list files and directories (same as ls)"), ("cd", "change directory — Tab completes folders"), + ("ps", "list processes"), + ("where", "filter the last table — where size > 1mb"), + ("get", "select a column — get name"), ("help", "show this help"), ("version", "print the version"), ("exit", "quit the shell"), @@ -118,8 +124,8 @@ impl AmCompleter { commands: vec![ "list", "status", "search", "info", "install", "uninstall", "update", "start", "stop", "restart", "run", "doctor", "config", "completion", - "self-update", "self-uninstall", "export", "import", "shell", "ls", "cd", - "help", "version", "exit", + "self-update", "self-uninstall", "export", "import", "shell", "ls", "dir", + "cd", "ps", "where", "get", "help", "version", "exit", ], config_sub: vec!["show", "path", "edit", "validate", "add"], shells: vec!["bash", "zsh", "fish", "powershell", "elvish"], @@ -171,7 +177,7 @@ impl AmCompleter { let cwd = current_dir(); pool = crate::nav::path_candidates(&cwd, word, true); } - "ls" => { + "ls" | "dir" => { // Complete paths (files and directories). let cwd = current_dir(); pool = crate::nav::path_candidates(&cwd, word, false); @@ -194,6 +200,11 @@ impl AmCompleter { _ => { if word.starts_with('-') { pool = self.options.iter().map(|s| s.to_string()).collect(); + } else { + // Nushell fallback: complete file paths for external + // (system) commands when nothing else applies. + let cwd = current_dir(); + pool = crate::nav::path_candidates(&cwd, word, false); } } } @@ -406,7 +417,9 @@ pub fn banner_box( " system doctor · config · completion · self-update · self-uninstall · export · import" .to_string(), )); - rows.push(inner(" shell ls · cd · shell · help · version · exit".to_string())); + rows.push(inner( + " shell ls · cd · ps · where · get · shell · help · version · exit".to_string(), + )); // Stats footer. rows.push(blank()); @@ -648,11 +661,17 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result { let mut rl: rustyline::Editor = rustyline::Editor::new()?; rl.set_helper(Some(AmCompleter::new(app))); + // Nushell-style completion: a single match is inserted immediately; + // otherwise Tab inserts the longest common prefix and the menu lists the + // candidates with their descriptions. + use rustyline::config::{CompletionType, Configurer}; + let _ = rl.set_completion_type(CompletionType::List); let history_path = app.paths.state_file.with_file_name("history.txt"); if rl.load_history(&history_path).is_err() { // First run: no history yet. } let started = Instant::now(); + let mut last: Option = None; loop { print_status(app, session, started); match rl.readline("❯ ") { @@ -662,7 +681,7 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result { continue; } let _ = rl.add_history_entry(trimmed.as_str()); - match handle_input(app, session, &trimmed) { + match handle_input(app, session, &mut last, &trimmed) { Ok(true) => break, Ok(false) => {} Err(e) => app.log.error(&format!("{e:#}")), @@ -683,6 +702,7 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result { /// Plain fallback when no terminal/line editor is available. fn run_plain(app: &App, session: &mut ShellSession) -> Result { let started = Instant::now(); + let mut last: Option = None; loop { print_status(app, session, started); eprint!("❯ "); @@ -695,7 +715,7 @@ fn run_plain(app: &App, session: &mut ShellSession) -> Result { if line.is_empty() { continue; } - match handle_input(app, session, &line) { + match handle_input(app, session, &mut last, &line) { Ok(true) => break, Ok(false) => {} Err(e) => app.log.error(&format!("{e:#}")), @@ -705,7 +725,12 @@ fn run_plain(app: &App, session: &mut ShellSession) -> Result { } /// Handle one line of input. Returns true when the session should exit. -fn handle_input(app: &App, session: &mut ShellSession, line: &str) -> Result { +fn handle_input( + app: &App, + session: &mut ShellSession, + last: &mut Option, + line: &str, +) -> Result { match line { "exit" | "quit" | "q" | "/exit" | "/quit" => return Ok(true), "help" | "?" | "/help" => { @@ -732,12 +757,21 @@ fn handle_input(app: &App, session: &mut ShellSession, line: &str) -> Result Result { +fn handle_line( + app: &App, + session: &mut ShellSession, + last: &mut Option, + line: &str, +) -> Result { + // Nushell-style pipelines: ls | where ... | get ... + if line.contains('|') { + return eval_pipeline(app, last, line); + } // '!' forces system execution even for words that look like am commands. if let Some(rest) = line.strip_prefix('!') { let rest = rest.trim(); @@ -762,9 +796,38 @@ fn handle_line(app: &App, session: &mut ShellSession, line: &str) -> Result 1mb)"))?; + if rest.len() < 3 { + anyhow::bail!("usage: where "); + } + let filtered = + crate::tables::filter_where(&base, &rest[0], &rest[1], &rest[2..].join(" "))?; + show_table(app, last, filtered); + return Ok(false); + } + if cmd == "get" { + let base = last + .clone() + .ok_or_else(|| anyhow!("'get' needs a table — run ls or ps first (e.g. ls | get name)"))?; + let field = rest + .first() + .ok_or_else(|| anyhow!("usage: get "))?; + let column = crate::tables::get_column(&base, field)?; + show_table(app, last, column); return Ok(false); } // Everything that is not an am command is a system command. @@ -910,14 +973,76 @@ fn is_am_command(word: &str) -> bool { ) } -/// Nushell-style listing of the current (or given) directory. -fn ls_command(app: &App, rest: &[String]) -> Result<()> { - let target = match rest.first() { +/// Resolve the path argument of ls/dir (defaults to the current directory). +fn ls_target(rest: &[String]) -> Result { + Ok(match rest.first() { Some(p) => crate::config::expand_path(p), None => current_dir(), - }; - print!("{}", nav::render_ls(&target, app.color())?); - Ok(()) + }) +} + +/// Print a table and remember it as the last result (for where/get). +fn show_table(app: &App, last: &mut Option, table: DataTable) { + print!("{}", table.render(app.color())); + *last = Some(table); +} + +/// Evaluate a Nushell-style pipeline: ls | ps | where | get, in order. +fn eval_pipeline(app: &App, last: &mut Option, line: &str) -> Result { + let parts: Vec<&str> = line + .split('|') + .map(str::trim) + .filter(|p| !p.is_empty()) + .collect(); + let mut table: Option = None; + for part in &parts { + let tokens = + shell_words::split(part).map_err(|e| anyhow!("cannot parse command: {e}"))?; + if tokens.is_empty() { + continue; + } + let cmd = tokens[0].as_str(); + let rest = &tokens[1..]; + match cmd { + "ls" => { + table = Some(nav::ls_table(&ls_target(rest)?, app.color())?); + } + "dir" if cfg!(windows) => { + table = Some(nav::ls_table(&ls_target(rest)?, app.color())?); + } + "ps" => { + table = Some(ps::ps_table()); + } + "where" => { + let base = table + .as_ref() + .ok_or_else(|| anyhow!("'where' needs a table before it"))?; + if rest.len() < 3 { + anyhow::bail!("usage: where "); + } + table = Some(crate::tables::filter_where( + base, + &rest[0], + &rest[1], + &rest[2..].join(" "), + )?); + } + "get" => { + let base = table + .as_ref() + .ok_or_else(|| anyhow!("'get' needs a table before it"))?; + let field = rest.first().ok_or_else(|| anyhow!("usage: get "))?; + table = Some(crate::tables::get_column(base, field)?); + } + other => anyhow::bail!( + "'{other}' cannot be used in a pipeline — use ls, ps, where or get" + ), + } + } + if let Some(t) = table { + show_table(app, last, t); + } + Ok(false) } /// Run one system command through the active shell (inherited stdio). @@ -999,14 +1124,21 @@ fn print_help() { println!(" export [--output FILE] backup config + state"); println!(" import restore a backup"); println!(" shell [list|] show or switch the system shell"); - println!(" ls [path] list files and directories (Nushell-style table)"); + println!(" ls [path] · dir [path] list files and directories (Nushell-style table)"); + println!(" ps list processes"); + println!(" where filter the last table (ls | where size > 1mb)"); + println!(" get select a column (ls | get name)"); println!(" cd change directory — Tab completes folders"); println!(" help | version | exit this help, the version, quit"); println!(); + println!("pipelines:"); + println!(" ls | where size > 1mb | get name"); + println!(" ps | where name =~ am"); + println!(); println!("system gateway:"); println!(" any other input runs as a system command in the active shell"); println!(" prefix with '!' to force system execution (e.g. '!ls', '!list')"); - println!(" Tab completes commands, agents, flags and paths (cycle with Tab)"); + println!(" Tab inserts the common prefix and lists candidates with descriptions"); println!(); println!("slash commands: /help · /version · /exit · /shell "); } diff --git a/src/tables.rs b/src/tables.rs new file mode 100644 index 0000000..feb610b --- /dev/null +++ b/src/tables.rs @@ -0,0 +1,328 @@ +//! Generic Nushell-style tables: render a boxed table, filter rows with +//! where-like predicates and select a column with get. + +use anyhow::{bail, Result}; +use colored::Colorize; + +/// One cell: the text to display plus a typed value for filtering +/// (number, byte size, or plain string). +#[derive(Debug, Clone)] +pub struct Cell { + pub text: String, + pub num: Option, + pub bytes: Option, +} + +impl Cell { + pub fn str(s: impl Into) -> Self { + Cell { + text: s.into(), + num: None, + bytes: None, + } + } + + pub fn num(n: f64, text: String) -> Self { + Cell { + text, + num: Some(n), + bytes: None, + } + } + + pub fn bytes(b: u64, text: String) -> Self { + Cell { + text, + num: None, + bytes: Some(b), + } + } +} + +/// A tabular result produced by ls, ps, where or get. +#[derive(Debug, Clone, Default)] +pub struct DataTable { + pub columns: Vec, + pub rows: Vec>, +} + +impl DataTable { + pub fn column_index(&self, name: &str) -> Option { + self.columns + .iter() + .position(|c| c.eq_ignore_ascii_case(name)) + } + + /// Render the boxed Nushell-style table. + pub fn render(&self, color: bool) -> String { + let headers = self.columns.clone(); + // 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 = headers.iter().map(|h| visible_len(h)).collect(); + let mut cells: Vec> = Vec::new(); + for (i, row) in self.rows.iter().enumerate() { + let mut line = vec![i.to_string()]; + for cell in row { + line.push(cell.text.clone()); + } + for (c, cell) in line.iter().enumerate().skip(1) { + widths[c - 1] = widths[c - 1].max(visible_len(cell)); + } + cells.push(line); + } + let all_headers: Vec = std::iter::once("#".to_string()) + .chain(headers.iter().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); + for (i, w) in all_widths.iter().enumerate() { + if i > 0 { + line.push(mid); + } + line.push_str(&"─".repeat(w + 2)); + } + line.push(right); + line + }; + let row_line = |row: &[String], bold: bool| -> String { + let mut line = String::from("│"); + for (i, c) in row.iter().enumerate() { + line.push(' '); + let cell = pad(c, all_widths[i]); + let rendered = if bold { + cell.bold().to_string() + } else { + cell + }; + line.push_str(&rendered); + line.push(' '); + line.push('│'); + } + line + }; + let dim = |s: &str| -> String { + if color { + s.dimmed().to_string() + } else { + s.to_string() + } + }; + + let mut out = String::new(); + out.push_str(&dim(&border('╭', '┬', '╮'))); + out.push('\n'); + out.push_str(&row_line(&all_headers, true)); + out.push('\n'); + out.push_str(&dim(&border('├', '┼', '┤'))); + out.push('\n'); + for line in &cells { + out.push_str(&row_line(line, false)); + out.push('\n'); + } + out.push_str(&dim(&border('╰', '┴', '╯'))); + out.push('\n'); + out + } +} + +/// Parse a where value: "1mb"/"512kb"/"2gb" become byte sizes, numbers +/// become numbers, everything else is a string. +#[derive(Debug, Clone, PartialEq)] +enum WhereValue { + Num(f64), + Bytes(u64), + Str(String), +} + +fn parse_value(raw: &str) -> WhereValue { + let lower = raw.trim().to_lowercase(); + for (suffix, mult) in [("gb", 1024u64.pow(3)), ("mb", 1024u64.pow(2)), ("kb", 1024)] { + if let Some(n) = lower.strip_suffix(suffix) { + if let Ok(v) = n.trim().parse::() { + return WhereValue::Bytes((v * mult as f64) as u64); + } + } + } + if let Ok(v) = raw.trim().parse::() { + return WhereValue::Num(v); + } + WhereValue::Str(raw.trim().to_string()) +} + +fn num_cmp(a: f64, b: f64, op: &str) -> bool { + match op { + ">" => a > b, + "<" => a < b, + ">=" => a >= b, + "<=" => a <= b, + "==" => (a - b).abs() < f64::EPSILON, + "!=" => (a - b).abs() >= f64::EPSILON, + _ => false, + } +} + +/// Filter the rows of a table: where . +pub fn filter_where(table: &DataTable, field: &str, op: &str, value: &str) -> Result { + let Some(idx) = table.column_index(field) else { + bail!( + "unknown column '{field}' — available: {}", + table.columns.join(", ") + ); + }; + let val = parse_value(value); + let supported = matches!(op, ">" | "<" | ">=" | "<=" | "==" | "!=" | "=~"); + if !supported { + bail!("unknown operator '{op}' — use > < >= <= == != =~"); + } + let mut out = table.clone(); + out.rows.retain(|row| match row.get(idx) { + None => false, + Some(cell) => match &val { + WhereValue::Num(n) => match cell.num { + Some(v) => num_cmp(v, *n, op), + None => false, + }, + WhereValue::Bytes(b) => match cell.bytes { + Some(v) => num_cmp(v as f64, *b as f64, op), + None => false, + }, + WhereValue::Str(s) => { + let text = strip_ansi(&cell.text); + match op { + "==" => text.eq_ignore_ascii_case(s), + "!=" => !text.eq_ignore_ascii_case(s), + "=~" => text.to_lowercase().contains(&s.to_lowercase()), + _ => false, + } + } + }, + }); + Ok(out) +} + +/// Select one column: get . +pub fn get_column(table: &DataTable, field: &str) -> Result { + let Some(idx) = table.column_index(field) else { + bail!( + "unknown column '{field}' — available: {}", + table.columns.join(", ") + ); + }; + Ok(DataTable { + columns: vec![field.to_string()], + rows: table + .rows + .iter() + .map(|row| vec![row[idx].clone()]) + .collect(), + }) +} + +fn strip_ansi(s: &str) -> String { + let mut out = String::new(); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '\u{1b}' { + for n in chars.by_ref() { + if n == 'm' { + break; + } + } + } else { + out.push(c); + } + } + out +} + +fn visible_len(s: &str) -> usize { + strip_ansi(s).chars().count() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> DataTable { + DataTable { + columns: vec!["name".into(), "type".into(), "size".into()], + rows: vec![ + vec![ + Cell::str("src"), + Cell::str("dir"), + Cell::bytes(2048, "2.0 KB".into()), + ], + vec![ + Cell::str("am.exe"), + Cell::str("file"), + Cell::bytes(5_000_000, "4.8 MB".into()), + ], + vec![ + Cell::str("readme.md"), + Cell::str("file"), + Cell::bytes(512, "512 B".into()), + ], + ], + } + } + + #[test] + fn filters_numeric_and_size() { + let t = sample(); + let big = filter_where(&t, "size", ">", "1mb").unwrap(); + assert_eq!(big.rows.len(), 1); + assert_eq!(big.rows[0][0].text, "am.exe"); + let eq = filter_where(&t, "type", "==", "dir").unwrap(); + assert_eq!(eq.rows.len(), 1); + let contains = filter_where(&t, "name", "=~", "read").unwrap(); + assert_eq!(contains.rows.len(), 1); + } + + #[test] + fn get_selects_column() { + let t = sample(); + let names = get_column(&t, "name").unwrap(); + assert_eq!(names.columns, vec!["name".to_string()]); + assert_eq!(names.rows.len(), 3); + assert_eq!(names.rows[1][0].text, "am.exe"); + } + + #[test] + fn unknown_column_errors() { + let t = sample(); + assert!(filter_where(&t, "nope", "==", "x").is_err()); + assert!(get_column(&t, "nope").is_err()); + } + + #[test] + fn parses_where_values() { + assert_eq!(parse_value("1mb"), WhereValue::Bytes(1024 * 1024)); + assert_eq!(parse_value("512kb"), WhereValue::Bytes(512 * 1024)); + assert_eq!(parse_value("3.5"), WhereValue::Num(3.5)); + assert_eq!(parse_value("hello"), WhereValue::Str("hello".into())); + } + + #[test] + fn render_is_aligned() { + let t = sample(); + let text = t.render(false); + assert!(text.contains("╭")); + assert!(text.contains("name")); + assert!(text.contains("am.exe")); + let widths: Vec = text.lines().map(|l| l.chars().count()).collect(); + assert!(widths.iter().all(|w| *w == widths[0]), "unaligned:\n{text}"); + } +}