From 296b095fb3b27a55559e39e77ddb3dacde49359b Mon Sep 17 00:00:00 2001 From: Ankit Kumar Date: Wed, 21 Oct 2026 21:13:25 +0530 Subject: [PATCH 1/9] feat(sys,config): report used/total RAM and fix [ui] accent config scoping --- src/config.rs | 53 +++++++++++++++++++++++++++++++------------------- src/sys.rs | 54 ++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 73 insertions(+), 34 deletions(-) diff --git a/src/config.rs b/src/config.rs index 3cd708c..e266cb2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -88,26 +88,7 @@ impl Config { let _ = std::fs::create_dir_all(parent); } let current = std::fs::read_to_string(&p).unwrap_or_else(|_| DEFAULT_TOML.to_string()); - let mut new_lines = Vec::new(); - let mut found = false; - for line in current.lines() { - let trimmed = line.trim_start(); - if trimmed.starts_with("accent =") || trimmed.starts_with("accent=") { - let comment = if let Some((_, c)) = line.split_once('#') { - format!(" #{}", c) - } else { - String::new() - }; - new_lines.push(format!("accent = \"{}\"{}", accent, comment)); - found = true; - } else { - new_lines.push(line.to_string()); - } - } - if !found { - new_lines.push(format!("accent = \"{}\"", accent)); - } - let _ = std::fs::write(&p, new_lines.join("\n") + "\n"); + let _ = std::fs::write(&p, format_accent_toml(¤t, accent)); } pub fn is_bookmarked(&self, path: &str) -> bool { @@ -193,10 +174,42 @@ pub fn format_bookmarks_toml(current: &str, bookmarks: &[String]) -> String { joined + "\n" } +pub fn format_accent_toml(current: &str, accent: &str) -> String { + let mut new_lines = Vec::new(); + let mut found = false; + for line in current.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with("accent =") || trimmed.starts_with("accent=") { + let comment = line.split_once('#').map(|(_, c)| format!(" #{c}")).unwrap_or_default(); + new_lines.push(format!("accent = \"{accent}\"{comment}")); + found = true; + } else { + new_lines.push(line.to_string()); + } + } + if !found { + if let Some(pos) = new_lines.iter().position(|l| l.trim() == "[ui]") { + new_lines.insert(pos + 1, format!("accent = \"{accent}\"")); + } else { + new_lines.push("[ui]".into()); + new_lines.push(format!("accent = \"{accent}\"")); + } + } + new_lines.join("\n") + "\n" +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn test_set_accent_missing_key() { + let initial = "[ui]\nanimations = true\n\n[safety]\nconfirm_destructive = true\n"; + let updated = format_accent_toml(initial, "magenta"); + let cfg: Config = toml::from_str(&updated).unwrap(); + assert_eq!(cfg.ui.accent, "magenta"); + } + #[test] fn test_format_and_parse_bookmarks() { let initial = r#"[ui] diff --git a/src/sys.rs b/src/sys.rs index a81215a..03b7d6c 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -160,36 +160,62 @@ pub fn battery() -> Option { #[cfg(target_os = "macos")] pub fn memory() -> Option { use std::ffi::CString; - let mut memsize: u64 = 0; + let mut total: u64 = 0; let mut len = std::mem::size_of::(); let name = CString::new("hw.memsize").unwrap(); - let res = unsafe { libc::sysctlbyname(name.as_ptr(), &mut memsize as *mut _ as *mut libc::c_void, &mut len, std::ptr::null_mut(), 0) }; - if res == 0 && memsize > 0 { - Some(format_bytes(memsize)) - } else { - None + if unsafe { libc::sysctlbyname(name.as_ptr(), &mut total as *mut _ as *mut libc::c_void, &mut len, std::ptr::null_mut(), 0) } != 0 || total == 0 { + return None; + } + if let Ok(out) = std::process::Command::new("vm_stat").output() { + let text = String::from_utf8_lossy(&out.stdout); + let page_size: u64 = text.lines().next() + .and_then(|l| l.split("page size of ").nth(1)) + .and_then(|s| s.split_whitespace().next()) + .and_then(|n| n.parse().ok()) + .unwrap_or(4096); + let mut used_pages = 0u64; + for line in text.lines() { + if line.starts_with("Pages active:") || line.starts_with("Pages wired down:") || line.starts_with("Pages occupied by compressor:") { + if let Some(v) = line.split(':').nth(1).and_then(|s| s.trim().trim_end_matches('.').parse::().ok()) { + used_pages += v; + } + } + } + if used_pages > 0 { + let used = (used_pages * page_size).min(total); + return Some(format!("{} / {} ({:.0}%)", format_bytes(used), format_bytes(total), (used as f64 / total as f64) * 100.0)); + } } + Some(format_bytes(total)) } #[cfg(target_os = "linux")] pub fn memory() -> Option { if let Ok(s) = std::fs::read_to_string("/proc/meminfo") { + let (mut total_kb, mut avail_kb) = (None, None); for line in s.lines() { if line.starts_with("MemTotal:") { - let parts: Vec<&str> = line.split_whitespace().collect(); - if let Some(kb_str) = parts.get(1) { - if let Ok(kb) = kb_str.parse::() { - return Some(format_bytes(kb * 1024)); - } - } + total_kb = line.split_whitespace().nth(1).and_then(|v| v.parse::().ok()); + } else if line.starts_with("MemAvailable:") { + avail_kb = line.split_whitespace().nth(1).and_then(|v| v.parse::().ok()); + } + } + if let Some(t) = total_kb { + let total = t * 1024; + if let Some(a) = avail_kb { + let used = total.saturating_sub(a * 1024); + return Some(format!("{} / {} ({:.0}%)", format_bytes(used), format_bytes(total), (used as f64 / total as f64) * 100.0)); } + return Some(format_bytes(total)); } } let mut info: libc::sysinfo = unsafe { std::mem::zeroed() }; if unsafe { libc::sysinfo(&mut info) } == 0 { - let total = (info.totalram as u64) * (info.mem_unit as u64); + let unit = info.mem_unit as u64; + let total = (info.totalram as u64) * unit; if total > 0 { - return Some(format_bytes(total)); + let used = total.saturating_sub((info.freeram as u64) * unit); + return Some(format!("{} / {} ({:.0}%)", format_bytes(used), format_bytes(total), (used as f64 / total as f64) * 100.0)); } } None From ae89ad9eca556271a923bf4a093d34225c80b3b2 Mon Sep 17 00:00:00 2001 From: Ankit Kumar Date: Wed, 21 Oct 2026 21:17:11 +0530 Subject: [PATCH 2/9] feat(init,fs): add bash auto-exec macro, fish shell init, and gitignore search --- src/fs.rs | 24 +++++++++++++++- src/init.rs | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++--- src/main.rs | 4 +-- 3 files changed, 102 insertions(+), 7 deletions(-) diff --git a/src/fs.rs b/src/fs.rs index 345e97d..d4a4cb7 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -1,6 +1,7 @@ -use std::collections::VecDeque; +use std::collections::{HashSet, VecDeque}; use std::fs; use std::path::{Path, PathBuf}; +use std::process::Command; #[derive(Clone)] pub struct Entry { @@ -36,6 +37,24 @@ pub fn list_dir(dir: &Path, hidden: bool) -> Vec { /// Breadth-first recursive listing (shallow results first), bounded. pub fn walk(root: &Path, hidden: bool, max_depth: usize, cap: usize) -> Vec { const SKIP: [&str; 5] = ["node_modules", "target", "__pycache__", ".venv", ".git"]; + // shortcut: uses git for .gitignore outside hidden mode, switch to the `ignore` crate if non-git .ignore support is needed + let ignored: HashSet = if !hidden { + Command::new("git") + .args(["ls-files", "-oi", "--exclude-standard", "--directory"]) + .current_dir(root) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .lines() + .map(|l| l.trim_end_matches('/').to_string()) + .collect() + }) + .unwrap_or_default() + } else { + HashSet::new() + }; let mut out = vec![]; let mut q = VecDeque::from([(root.to_path_buf(), 0usize)]); while let Some((dir, depth)) = q.pop_front() { @@ -44,6 +63,9 @@ pub fn walk(root: &Path, hidden: bool, max_depth: usize, cap: usize) -> Vec String { if let Some(c) = k.strip_prefix("alt-") { return format!("\\e{c}"); } k } +fn fish_key(k: &str) -> String { + let k = k.to_lowercase(); + if k == "ctrl-space" || k == "ctrl-shift-space" { return "-k nul".into(); } + if let Some(c) = k.strip_prefix("ctrl-shift-").or_else(|| k.strip_prefix("shift-ctrl-")) { + return format!("\\c{}", c.to_lowercase()); + } + if let Some(c) = k.strip_prefix("ctrl-") { return format!("\\c{c}"); } + if let Some(c) = k.strip_prefix("alt-") { return format!("\\e{c}"); } + k +} /// Thin integration: the binary only ever prints ":"; the shell decides what to do with it. pub fn script(shell: &str, cfg: &Config) -> Option { @@ -79,7 +89,7 @@ bindkey '{}' tman-widget{} let primary = bash_key(key); let extra = if key.to_lowercase().contains("shift") && key.to_lowercase().contains("ctrl") { if key.to_lowercase().ends_with('t') { - "\nbind -x '\"\\C-@\": __tman_widget' 2>/dev/null || true\nbind -x '\"\\e[84;6u\": __tman_widget' 2>/dev/null || true\nbind -x '\"\\e[116;6u\": __tman_widget' 2>/dev/null || true\nbind -x '\"\\e[20;6~\": __tman_widget' 2>/dev/null || true" + "\nbind '\"\\C-@\": \"\\C-x\\C-t\\C-x\\C-e\"' 2>/dev/null || true\nbind '\"\\e[84;6u\": \"\\C-x\\C-t\\C-x\\C-e\"' 2>/dev/null || true\nbind '\"\\e[116;6u\": \"\\C-x\\C-t\\C-x\\C-e\"' 2>/dev/null || true\nbind '\"\\e[20;6~\": \"\\C-x\\C-t\\C-x\\C-e\"' 2>/dev/null || true" } else { "" } @@ -102,21 +112,84 @@ tman() {{ command tman "$@" fi }} -# bash cannot auto-accept a line from a `bind -x` handler, so results are always inserted for you to press Enter. __tman_widget() {{ - local hist out + local hist out mode hist=$(mktemp "${{TMPDIR:-/tmp}}/tman.XXXXXX") || return history -a; HISTTIMEFORMAT= history | sed 's/^ *[0-9]* *//' > "$hist" out=$(TMAN_HISTORY_FILE="$hist" command tman ui) rm -f "$hist" if [[ -n $out ]]; then + mode=${{out%%:*}} READLINE_LINE=${{out#*:}} READLINE_POINT=${{#READLINE_LINE}} + if [[ $mode == x ]]; then + bind '"\C-x\C-e": accept-line' + else + bind '"\C-x\C-e": ""' + fi + else + bind '"\C-x\C-e": ""' fi }} -bind -x '"{primary}": __tman_widget'{extra} +bind -x '"\C-x\C-t": __tman_widget' +bind '"\C-x\C-e": ""' +bind '"{primary}": "\C-x\C-t\C-x\C-e"'{extra} +"#)) + }, + "fish" => { + let primary = fish_key(key); + Some(format!(r#"# tman fish integration: tman init fish | source +function tman + if test (count $argv) -eq 0; or test "$argv[1]" = "ui" + set -l hist (mktemp (set -q TMPDIR; and echo $TMPDIR; or echo /tmp)/tman.XXXXXX); or return + builtin history > "$hist" 2>/dev/null + set -l out (TMAN_HISTORY_FILE="$hist" command tman ui) + rm -f "$hist" + if test -n "$out" + set -l mode (string split -m 1 : -- "$out")[1] + set -l cmd (string split -m 1 : -- "$out")[2] + if test "$mode" = "x" + eval "$cmd" + else + commandline -r -- "$cmd" + end + end + else + command tman $argv + end +end +function __tman_widget + set -l hist (mktemp (set -q TMPDIR; and echo $TMPDIR; or echo /tmp)/tman.XXXXXX); or return + builtin history > "$hist" 2>/dev/null + set -l out (TMAN_HISTORY_FILE="$hist" command tman ui) + rm -f "$hist" + if test -n "$out" + set -l mode (string split -m 1 : -- "$out")[1] + set -l cmd (string split -m 1 : -- "$out")[2] + commandline -r -- "$cmd" + if test "$mode" = "x" + commandline -f execute + end + end + commandline -f repaint +end +bind {primary} __tman_widget +bind -k nul __tman_widget 2>/dev/null; or true "#)) }, _ => None, } } + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn generates_all_shells() { + let cfg = Config::default(); + assert!(script("zsh", &cfg).unwrap().contains("zle accept-line")); + assert!(script("bash", &cfg).unwrap().contains("accept-line")); + assert!(script("fish", &cfg).unwrap().contains("commandline -f execute")); + assert!(script("csh", &cfg).is_none()); + } +} diff --git a/src/main.rs b/src/main.rs index beb308d..574c5d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,14 +17,14 @@ fn main() { match a.first().map(String::as_str) { Some("-h") | Some("--help") | Some("help") => { println!("tman {} - Context-aware interactive layer over your terminal", env!("CARGO_PKG_VERSION")); - println!("\nUsage:\n tman [command]\n\nCommands:\n ui Launch interactive TUI HUD (default)\n init Output shell integration script\n config-path Print configuration file path\n default-config Output default TOML configuration\n\nOptions:\n -h, --help Print help information\n -V, --version Print version information"); + println!("\nUsage:\n tman [command]\n\nCommands:\n ui Launch interactive TUI HUD (default)\n init Output shell integration script\n config-path Print configuration file path\n default-config Output default TOML configuration\n\nOptions:\n -h, --help Print help information\n -V, --version Print version information"); } Some("-V") | Some("--version") | Some("version") => { println!("tman {}", env!("CARGO_PKG_VERSION")); } Some("init") => match a.get(1).and_then(|s| init::script(s, &config::Config::load())) { Some(t) => print!("{t}"), - None => { eprintln!("usage: tman init "); exit(2) } + None => { eprintln!("usage: tman init "); exit(2) } }, Some("config-path") => println!("{}", config::path().display()), Some("default-config") => print!("{}", config::DEFAULT_TOML), From 0a74dedc765f8d69dcaa3820856832e8ff364cae Mon Sep 17 00:00:00 2001 From: Ankit Kumar Date: Wed, 21 Oct 2026 21:20:18 +0530 Subject: [PATCH 3/9] feat(git,run,ui): cache menu telemetry, git-root discovery, and space staging --- src/app.rs | 41 ++++++++++--- src/git.rs | 53 ++++++++++++++--- src/run.rs | 166 +++++++++++++++++++++++++++++++++-------------------- src/ui.rs | 4 ++ 4 files changed, 185 insertions(+), 79 deletions(-) diff --git a/src/app.rs b/src/app.rs index e4e4914..1fa2c95 100644 --- a/src/app.rs +++ b/src/app.rs @@ -44,7 +44,7 @@ fn act(name: &str, desc: &str, icon: &'static str, out: Outcome) -> Action { } pub enum Overlay { Danger(Cmd, &'static str), Preview(Cmd) } -#[derive(Default)] +#[derive(Default, Clone)] pub struct Preview { pub title: String, pub lines: Vec } pub struct App { @@ -61,6 +61,7 @@ pub struct App { pub searching: bool, pub overlay: Option, pub preview: Preview, + ctx_cache: Option, pub entered: Instant, pub quit: bool, pub result: Option, @@ -74,7 +75,7 @@ impl App { let mut a = App { show_hidden: cfg.ui.show_hidden, git: git::info(&cwd), start_cwd: cwd.clone(), cwd, cfg, mode: Mode::Menu, stack: vec![], items: vec![], filtered: vec![], sel: 0, query: String::new(), - searching: false, overlay: None, preview: Preview::default(), entered: Instant::now(), + searching: false, overlay: None, preview: Preview::default(), ctx_cache: None, entered: Instant::now(), quit: false, result: None, state: ListState::default(), hist: None, }; a.rebuild(); @@ -157,10 +158,13 @@ impl App { ] } Mode::GitFiles => git::changed_files(&self.cwd).into_iter().map(|(st, p)| { - let name = format!("{:<2} {}", st, p.strip_prefix(&self.cwd).unwrap_or(&p).display()); - Action { path: Some(p.clone()), out: Outcome::Goto(Mode::Actions(p)), ..act(&name, "", "·", Outcome::Goto(Mode::Menu)) } + let name = format!("{:<2} {}", st.trim(), p.strip_prefix(&self.cwd).unwrap_or(&p).display()); + Action { path: Some(p.clone()), out: Outcome::Goto(Mode::Actions(p)), ..act(&name, &st, "·", Outcome::Goto(Mode::Menu)) } }).collect(), - Mode::Run => run::discover(&self.cwd).into_iter().map(|r| act(&r.cmd, r.source, "▶", Outcome::Emit(Cmd::run(r.cmd.clone())))).collect(), + Mode::Run => { + let root = self.git.as_ref().map(|g| g.root.as_path()); + run::discover(&self.cwd, root).into_iter().map(|r| act(&r.cmd, &r.source, "▶", Outcome::Emit(Cmd::run(r.cmd.clone())))).collect() + } Mode::Actions(p) => { let q = self.rel(&p); let is_dir = p.is_dir(); @@ -189,6 +193,7 @@ impl App { v.push(act("Git diff", "Changes vs HEAD", "⎇", Outcome::Emit(Cmd::run(format!("git diff HEAD -- {q}"))))); v.push(act("Git history", "Commits touching this file", "⎇", Outcome::Emit(Cmd::run(format!("git log --oneline -n 20 -- {q}"))))); v.push(act("Blame", "Per-line authorship", "⎇", Outcome::Emit(Cmd::run(format!("git blame {q}"))))); + v.push(act("Discard changes", "Restore working-tree file to HEAD", "!", Outcome::Emit(Cmd::run(format!("git checkout -- {q}"))))); } v.push(act("Rename", "Pre-fills mv; you edit the target", "▸", Outcome::Emit(Cmd::edit(format!("mv -- {q} {q}"))))); v.push(act("Delete", "Always asks for confirmation", "!", Outcome::Emit(Cmd::run(format!("rm {}-- {q}", if is_dir { "-r " } else { "" }))))); @@ -256,6 +261,7 @@ impl App { fn chdir(&mut self, p: PathBuf, select: Option) { self.cwd = p; self.git = git::info(&self.cwd); + self.ctx_cache = None; self.query.clear(); self.rebuild(); if let Some(s) = select { @@ -273,7 +279,7 @@ impl App { pub fn update_preview(&mut self) { let Some(a) = self.current().map(|i| self.items[i].clone()) else { self.preview = if self.mode == Mode::Menu { - self.context() + self.cached_context() } else if self.mode == Mode::Bookmarks { Preview { title: "BOOKMARKS".into(), @@ -293,7 +299,7 @@ impl App { }; let mode = self.mode.clone(); self.preview = match mode { - Mode::Menu => self.context(), + Mode::Menu => self.cached_context(), Mode::GitFiles => { let p = a.path.clone().unwrap(); let mut lines = git::diff_preview(&self.cwd, &p, 80); @@ -352,6 +358,12 @@ impl App { } }; } + fn cached_context(&mut self) -> Preview { + if let Some(c) = &self.ctx_cache { return c.clone(); } + let c = self.context(); + self.ctx_cache = Some(c.clone()); + c + } fn context(&self) -> Preview { let mut l = vec![]; @@ -375,7 +387,7 @@ impl App { Some(g) => l.push(format!(" Git: ⎇ {} +{} ~{} ?{}", g.branch, g.staged, g.modified, g.untracked)), None => l.push(" Git: not a git repository".into()), } - let k = run::kinds(&self.cwd); + let k = run::kinds(&self.cwd, self.git.as_ref().map(|g| g.root.as_path())); if !k.is_empty() { l.push(format!(" Project: {}", k.join(", "))); } if self.cfg.is_bookmarked(&self.cwd.to_string_lossy()) { l.push(" Status: Pinned in Bookmarks".to_string()); @@ -416,6 +428,7 @@ impl App { } Outcome::ToggleBookmark(p) => { self.cfg.toggle_bookmark(p.to_string_lossy().to_string()); + self.ctx_cache = None; self.rebuild(); } } @@ -463,7 +476,19 @@ impl App { KeyCode::Char('/') if self.mode != Mode::Menu => self.searching = true, KeyCode::Char('q') => self.quit = true, KeyCode::Char('m') => { self.stack.clear(); self.mode = Mode::Menu; self.enter_mode(); } + KeyCode::Char(' ') if !self.searching && self.mode == Mode::GitFiles => { + if let Some(i) = self.current() { + if let Some(p) = self.items[i].path.clone() { + let st = self.items[i].desc.clone(); + git::toggle_stage(&self.cwd, &p, &st); + self.git = git::info(&self.cwd); + self.ctx_cache = None; + self.rebuild(); + } + } + } KeyCode::Char('b') if !self.searching => { + self.ctx_cache = None; match self.mode { Mode::Files => { if let Some(i) = self.current() { diff --git a/src/git.rs b/src/git.rs index 6942762..c3559d4 100644 --- a/src/git.rs +++ b/src/git.rs @@ -6,7 +6,8 @@ pub struct GitInfo { pub staged: usize, pub modified: usize, pub untracked: usize, - #[allow(dead_code)] + pub ahead: usize, + pub behind: usize, pub root: PathBuf, } @@ -20,11 +21,8 @@ fn git(cwd: &Path, args: &[&str]) -> Option { .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) } -pub fn info(cwd: &Path) -> Option { - let root = PathBuf::from(git(cwd, &["rev-parse", "--show-toplevel"])?.trim()); - let st = git(cwd, &["status", "--porcelain=v1", "-b"])?; - let mut lines = st.lines(); - let head = lines.next().unwrap_or("").trim_start_matches("## "); +fn parse_head(head: &str) -> (String, usize, usize) { + let head = head.trim_start_matches("## "); let branch = if let Some(b) = head.strip_prefix("No commits yet on ") { b.to_string() } else if head.starts_with("HEAD (no branch)") { @@ -32,6 +30,22 @@ pub fn info(cwd: &Path) -> Option { } else { head.split("...").next().unwrap_or(head).split(' ').next().unwrap_or(head).to_string() }; + let (mut ahead, mut behind) = (0, 0); + if let Some( bracket ) = head.split('[').nth(1).and_then(|s| s.strip_suffix(']')) { + for part in bracket.split(',') { + let p = part.trim(); + if let Some(n) = p.strip_prefix("ahead ").and_then(|v| v.parse().ok()) { ahead = n; } + if let Some(n) = p.strip_prefix("behind ").and_then(|v| v.parse().ok()) { behind = n; } + } + } + (branch, ahead, behind) +} + +pub fn info(cwd: &Path) -> Option { + let root = PathBuf::from(git(cwd, &["rev-parse", "--show-toplevel"])?.trim()); + let st = git(cwd, &["status", "--porcelain=v1", "-b"])?; + let mut lines = st.lines(); + let (branch, ahead, behind) = parse_head(lines.next().unwrap_or("")); let (mut staged, mut modified, mut untracked) = (0, 0, 0); for l in lines { let b = l.as_bytes(); @@ -49,7 +63,19 @@ pub fn info(cwd: &Path) -> Option { } } } - Some(GitInfo { branch, staged, modified, untracked, root }) + Some(GitInfo { branch, staged, modified, untracked, ahead, behind, root }) +} + +/// Stages an unstaged/untracked file, or unstages a purely staged file. +pub fn toggle_stage(cwd: &Path, path: &Path, status: &str) { + let p = path.to_string_lossy(); + let is_unstaged = status == "??" || status.ends_with('M') || status.ends_with('D') || status.len() == 1 && !status.starts_with('A'); + if status == "??" || status.chars().nth(1).map(|c| c != ' ').unwrap_or(false) { + let _ = git(cwd, &["add", "--", &p]); + } else if !is_unstaged || !status.is_empty() { + let _ = git(cwd, &["restore", "--staged", "--", &p]) + .or_else(|| git(cwd, &["reset", "HEAD", "--", &p])); + } } /// (status code, absolute path) @@ -62,7 +88,7 @@ pub fn changed_files(cwd: &Path) -> Vec<(String, PathBuf)> { .filter(|l| l.len() > 3) .map(|l| { let p = l[3..].rsplit(" -> ").next().unwrap_or(&l[3..]).trim_matches('"'); - (l[..2].trim().to_string(), root.join(p)) + (l[..2].to_string(), root.join(p)) }) .collect() } @@ -74,3 +100,14 @@ pub fn diff_preview(cwd: &Path, path: &Path, max: usize) -> Vec { .map(|s| s.lines().take(max).map(String::from).collect()) .unwrap_or_default() } + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn parses_branch_and_ahead_behind() { + assert_eq!(parse_head("## main...origin/main [ahead 2, behind 1]"), ("main".into(), 2, 1)); + assert_eq!(parse_head("## feat/ui...origin/feat/ui [ahead 3]"), ("feat/ui".into(), 3, 0)); + assert_eq!(parse_head("## HEAD (no branch)"), ("detached".into(), 0, 0)); + } +} diff --git a/src/run.rs b/src/run.rs index a51b068..4bb7094 100644 --- a/src/run.rs +++ b/src/run.rs @@ -1,12 +1,32 @@ +use crate::fs::shell_quote; use std::fs; use std::path::Path; pub struct RunItem { pub cmd: String, - pub source: &'static str, + pub source: String, } -pub fn kinds(cwd: &Path) -> Vec<&'static str> { +fn resolve<'a>(cwd: &'a Path, git_root: Option<&'a Path>, file: &str) -> Option<&'a Path> { + if cwd.join(file).exists() { + Some(cwd) + } else if let Some(r) = git_root.filter(|&r| r != cwd && r.join(file).exists()) { + Some(r) + } else { + None + } +} + +fn scoped(cwd: &Path, target_dir: &Path, cmd: impl Into) -> String { + let c = cmd.into(); + if target_dir == cwd { + c + } else { + format!("cd -- {} && {c}", shell_quote(&target_dir.to_string_lossy())) + } +} + +pub fn kinds(cwd: &Path, git_root: Option<&Path>) -> Vec<&'static str> { let mut k = vec![]; for (f, n) in [ ("Cargo.toml", "Rust"), @@ -25,7 +45,7 @@ pub fn kinds(cwd: &Path) -> Vec<&'static str> { ("Dockerfile", "Docker"), (".git", "Git"), ] { - if cwd.join(f).exists() && !k.contains(&n) { + if resolve(cwd, git_root, f).is_some() && !k.contains(&n) { k.push(n); } } @@ -33,71 +53,71 @@ pub fn kinds(cwd: &Path) -> Vec<&'static str> { } /// Discovers project-specific commands instead of hardcoding a list of the world. -pub fn discover(cwd: &Path) -> Vec { +pub fn discover(cwd: &Path, git_root: Option<&Path>) -> Vec { let mut v = vec![]; // Node.js (package.json) - if let Ok(s) = fs::read_to_string(cwd.join("package.json")) { - if let Ok(j) = serde_json::from_str::(&s) { - let pm = if cwd.join("pnpm-lock.yaml").exists() { "pnpm" } - else if cwd.join("yarn.lock").exists() { "yarn" } - else if cwd.join("bun.lockb").exists() { "bun" } else { "npm" }; - if let Some(scripts) = j.get("scripts").and_then(|s| s.as_object()) { - for k in scripts.keys() { - let cmd = if pm == "npm" && (k == "test" || k == "start") { format!("npm {k}") } else { format!("{pm} run {k}") }; - v.push(RunItem { cmd, source: "package.json" }); + if let Some(dir) = resolve(cwd, git_root, "package.json") { + if let Ok(s) = fs::read_to_string(dir.join("package.json")) { + if let Ok(j) = serde_json::from_str::(&s) { + let pm = if dir.join("pnpm-lock.yaml").exists() { "pnpm" } + else if dir.join("yarn.lock").exists() { "yarn" } + else if dir.join("bun.lockb").exists() { "bun" } else { "npm" }; + if let Some(scripts) = j.get("scripts").and_then(|s| s.as_object()) { + for (k, val) in scripts { + let raw = if pm == "npm" && (k == "test" || k == "start") { format!("npm {k}") } else { format!("{pm} run {k}") }; + let source = val.as_str().map(|b| format!("package.json: {b}")).unwrap_or_else(|| "package.json".into()); + v.push(RunItem { cmd: scoped(cwd, dir, raw), source }); + } } } } } // Rust (Cargo.toml) - if cwd.join("Cargo.toml").exists() { + if resolve(cwd, git_root, "Cargo.toml").is_some() { for c in ["cargo build", "cargo run", "cargo test", "cargo check", "cargo clippy"] { - v.push(RunItem { cmd: c.into(), source: "Cargo.toml" }); + v.push(RunItem { cmd: c.into(), source: "Cargo.toml".into() }); } } // Python (.venv, pyproject.toml, requirements.txt, main.py) - let is_python = cwd.join("pyproject.toml").exists() - || cwd.join("requirements.txt").exists() - || cwd.join(".venv").exists() - || cwd.join("venv").exists() - || cwd.join("main.py").exists() - || cwd.join("app.py").exists(); - if is_python { - if cwd.join(".venv").exists() { - v.push(RunItem { cmd: "source .venv/bin/activate".into(), source: "Python (venv)" }); - } else if cwd.join("venv").exists() { - v.push(RunItem { cmd: "source venv/bin/activate".into(), source: "Python (venv)" }); + let py_dir = ["pyproject.toml", "requirements.txt", ".venv", "venv", "main.py", "app.py"] + .into_iter() + .find_map(|f| resolve(cwd, git_root, f)); + if let Some(dir) = py_dir { + if dir.join(".venv").exists() { + v.push(RunItem { cmd: scoped(cwd, dir, "source .venv/bin/activate"), source: "Python (venv)".into() }); + } else if dir.join("venv").exists() { + v.push(RunItem { cmd: scoped(cwd, dir, "source venv/bin/activate"), source: "Python (venv)".into() }); } - if cwd.join("poetry.lock").exists() { - v.push(RunItem { cmd: "poetry run python main.py".into(), source: "Poetry" }); - v.push(RunItem { cmd: "poetry run pytest".into(), source: "Poetry" }); - v.push(RunItem { cmd: "poetry install".into(), source: "Poetry" }); + if dir.join("poetry.lock").exists() { + v.push(RunItem { cmd: scoped(cwd, dir, "poetry run python main.py"), source: "Poetry".into() }); + v.push(RunItem { cmd: scoped(cwd, dir, "poetry run pytest"), source: "Poetry".into() }); + v.push(RunItem { cmd: scoped(cwd, dir, "poetry install"), source: "Poetry".into() }); } - if cwd.join("main.py").exists() { - v.push(RunItem { cmd: "python3 main.py".into(), source: "Python" }); - } else if cwd.join("app.py").exists() { - v.push(RunItem { cmd: "python3 app.py".into(), source: "Python" }); + if dir.join("main.py").exists() { + v.push(RunItem { cmd: scoped(cwd, dir, "python3 main.py"), source: "Python".into() }); + } else if dir.join("app.py").exists() { + v.push(RunItem { cmd: scoped(cwd, dir, "python3 app.py"), source: "Python".into() }); } - if cwd.join("pytest.ini").exists() || cwd.join("tests").is_dir() { - v.push(RunItem { cmd: "pytest".into(), source: "Python (pytest)" }); + if dir.join("pytest.ini").exists() || dir.join("tests").is_dir() { + v.push(RunItem { cmd: scoped(cwd, dir, "pytest"), source: "Python (pytest)".into() }); } - if cwd.join("requirements.txt").exists() { - v.push(RunItem { cmd: "pip install -r requirements.txt".into(), source: "Python" }); + if dir.join("requirements.txt").exists() { + v.push(RunItem { cmd: scoped(cwd, dir, "pip install -r requirements.txt"), source: "Python".into() }); } } // Go (go.mod) - if cwd.join("go.mod").exists() { + if let Some(dir) = resolve(cwd, git_root, "go.mod") { for c in ["go run .", "go test ./...", "go build", "go mod tidy"] { - v.push(RunItem { cmd: c.into(), source: "Go" }); + v.push(RunItem { cmd: scoped(cwd, dir, c), source: "Go".into() }); } } // Docker (docker-compose, Dockerfile) - if cwd.join("docker-compose.yml").exists() || cwd.join("compose.yaml").exists() { + if let Some(dir) = resolve(cwd, git_root, "docker-compose.yml").or_else(|| resolve(cwd, git_root, "compose.yaml")) { for c in [ "docker compose up -d", "docker compose down", @@ -105,49 +125,69 @@ pub fn discover(cwd: &Path) -> Vec { "docker compose restart", "docker compose ps", ] { - v.push(RunItem { cmd: c.into(), source: "Docker Compose" }); + v.push(RunItem { cmd: scoped(cwd, dir, c), source: "Docker Compose".into() }); } - } else if cwd.join("Dockerfile").exists() { - v.push(RunItem { cmd: "docker build -t app .".into(), source: "Dockerfile" }); + } else if let Some(dir) = resolve(cwd, git_root, "Dockerfile") { + v.push(RunItem { cmd: scoped(cwd, dir, "docker build -t app ."), source: "Dockerfile".into() }); } // Makefile - if let Ok(s) = fs::read_to_string(cwd.join("Makefile")) { - for l in s.lines() { - if let Some((t, _)) = l.split_once(':') { - if !t.is_empty() && !t.starts_with('.') && !l.starts_with('\t') && !l.contains('=') - && t.chars().all(|c| c.is_ascii_alphanumeric() || "_-".contains(c)) - { - v.push(RunItem { cmd: format!("make {t}"), source: "Makefile" }); + if let Some(dir) = resolve(cwd, git_root, "Makefile") { + if let Ok(s) = fs::read_to_string(dir.join("Makefile")) { + let lines: Vec<&str> = s.lines().collect(); + for (idx, l) in lines.iter().enumerate() { + if let Some((t, _)) = l.split_once(':') { + if !t.is_empty() && !t.starts_with('.') && !l.starts_with('\t') && !l.contains('=') + && t.chars().all(|c| c.is_ascii_alphanumeric() || "_-".contains(c)) + { + let source = lines.get(idx + 1).filter(|nl| nl.starts_with('\t')).map(|nl| format!("Makefile: {}", nl.trim())).unwrap_or_else(|| "Makefile".into()); + v.push(RunItem { cmd: scoped(cwd, dir, format!("make {t}")), source }); + } } } } } // CMake (CMakeLists.txt) - if cwd.join("CMakeLists.txt").exists() { + if let Some(dir) = resolve(cwd, git_root, "CMakeLists.txt") { for c in ["cmake -B build", "cmake --build build", "ctest --test-dir build"] { - v.push(RunItem { cmd: c.into(), source: "CMakeLists.txt" }); + v.push(RunItem { cmd: scoped(cwd, dir, c), source: "CMakeLists.txt".into() }); } } // Justfile for name in ["justfile", "Justfile"] { - if let Ok(s) = fs::read_to_string(cwd.join(name)) { - for l in s.lines() { - let trimmed = l.trim(); - if let Some((t, _)) = trimmed.split_once(':') { - let target = t.trim(); - if !target.is_empty() && !target.starts_with('#') && !target.contains('=') - && target.chars().all(|c| c.is_ascii_alphanumeric() || "_-".contains(c)) - { - v.push(RunItem { cmd: format!("just {target}"), source: "Justfile" }); + if let Some(dir) = resolve(cwd, git_root, name) { + if let Ok(s) = fs::read_to_string(dir.join(name)) { + let lines: Vec<&str> = s.lines().collect(); + for (idx, l) in lines.iter().enumerate() { + let trimmed = l.trim(); + if let Some((t, _)) = trimmed.split_once(':') { + let target = t.split_whitespace().next().unwrap_or(""); + if !target.is_empty() && !target.starts_with('#') && !target.contains('=') + && target.chars().all(|c| c.is_ascii_alphanumeric() || "_-".contains(c)) + { + let source = lines.get(idx + 1).filter(|nl| nl.starts_with(' ') || nl.starts_with('\t')).map(|nl| format!("Justfile: {}", nl.trim())).unwrap_or_else(|| "Justfile".into()); + v.push(RunItem { cmd: scoped(cwd, dir, format!("just {target}")), source }); + } } } + break; } - break; } } v } + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn discovers_from_git_root_when_in_subdir() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let sub = root.join("src"); + let items = discover(&sub, Some(root)); + assert!(items.iter().any(|i| i.cmd == "cargo test" && i.source == "Cargo.toml")); + } +} diff --git a/src/ui.rs b/src/ui.rs index 9d090eb..093b5ee 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -116,6 +116,9 @@ pub fn draw(f: &mut Frame, app: &mut App) { let mut h = vec![Span::styled(display_path(&app.cwd), Style::default().add_modifier(Modifier::BOLD))]; if let Some(g) = &app.git { h.push(Span::styled(format!(" ⎇ {}", g.branch), Style::default().fg(Color::Rgb(80, 220, 150)))); + if g.ahead > 0 || g.behind > 0 { + h.push(Span::styled(format!(" ↑{} ↓{}", g.ahead, g.behind), Style::default().fg(acc))); + } h.push(Span::styled(format!(" +{} ~{} ?{}", g.staged, g.modified, g.untracked), Style::default().fg(DIM))); } f.render_widget(Paragraph::new(Line::from(h)).block(block("tman".into(), acc)), rows[0]); @@ -202,6 +205,7 @@ fn footer(app: &App, acc: Color) -> Line<'static> { Mode::Bookmarks => &[("↑↓", "Move"), ("Enter", "Jump / cd"), ("d / x", "Delete pin"), ("←", "Back"), ("Esc", "Close")], Mode::History => &[("type", "Search"), ("↑↓", "Move"), ("Enter", "Run"), ("Tab", "Edit first"), ("←", "Back"), ("Esc", "Close")], Mode::Find => &[("type", "Search"), ("↑↓", "Move"), ("Enter", "cd / open"), ("Tab", "Actions"), ("←", "Back"), ("Esc", "Close")], + Mode::GitFiles => &[("↑↓", "Move"), ("Space", "Stage/Unstage"), ("Enter", "Actions"), ("/", "Search"), ("←", "Back"), ("Esc", "Close")], Mode::Theme => &[("↑↓", "Move"), ("Enter", "Apply & Save"), ("←", "Back"), ("Esc", "Close")], _ => &[("↑↓", "Move"), ("Enter", "Select"), ("Tab", "Edit first"), ("←", "Back"), ("/", "Search"), ("Esc", "Close")], } From 0fb60560eef82a3558a8439df82eaf18dd422598 Mon Sep 17 00:00:00 2001 From: Ankit Kumar Date: Wed, 21 Oct 2026 22:08:54 +0530 Subject: [PATCH 4/9] feat: add git branches/stashes picker, ports killer, fuzzy highlight, and cd-on-quit --- src/app.rs | 54 +++++++++++++++++++++++++++++++++++++++++++-------- src/config.rs | 17 ++++++++++++++++ src/fuzzy.rs | 35 ++++++++++++++++++++++++++++++--- src/git.rs | 32 ++++++++++++++++++++++++++++++ src/run.rs | 31 +++++++++++++++++++++++++++++ src/sys.rs | 51 ++++++++++++++++++++++++++++++++++++++++++++++++ src/ui.rs | 16 ++++++++++++--- 7 files changed, 222 insertions(+), 14 deletions(-) diff --git a/src/app.rs b/src/app.rs index 1fa2c95..ee7330e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,12 +14,13 @@ impl Cmd { } #[derive(Clone, Debug, PartialEq)] -pub enum Mode { Menu, Files, History, Find, Git, GitFiles, Run, Actions(PathBuf), Theme, Bookmarks } +pub enum Mode { Menu, Files, History, Find, Git, GitFiles, GitBranches, GitStashes, Run, Ports, Actions(PathBuf), Theme, Bookmarks } impl Mode { pub fn title(&self) -> &'static str { match self { Mode::Menu => "ACTIONS", Mode::Files => "NAVIGATE", Mode::History => "HISTORY", Mode::Find => "SEARCH", - Mode::Git => "GIT", Mode::GitFiles => "CHANGED FILES", Mode::Run => "RUN", Mode::Actions(_) => "FILE ACTIONS", + Mode::Git => "GIT", Mode::GitFiles => "CHANGED FILES", Mode::GitBranches => "BRANCHES", Mode::GitStashes => "STASHES", + Mode::Run => "RUN", Mode::Ports => "ACTIVE PORTS", Mode::Actions(_) => "FILE ACTIONS", Mode::Theme => "THEMES", Mode::Bookmarks => "BOOKMARKS", } } @@ -100,20 +101,28 @@ impl App { match self.mode.clone() { Mode::Menu => vec![ act("Navigate", "Browse and jump between directories", "▸", Outcome::Goto(Mode::Files)), - act("Bookmarks", "Quick jump to pinned directories", "*", Outcome::Goto(Mode::Bookmarks)), + act("Bookmarks", "Quick jump to pinned & recent directories", "*", Outcome::Goto(Mode::Bookmarks)), act("History", "Search your real shell history", "↺", Outcome::Goto(Mode::History)), act("Search", "Find files and folders below here", "⌕", Outcome::Goto(Mode::Find)), act("Git", "Repository state and operations", "⎇", Outcome::Goto(Mode::Git)), act("Run", "Commands discovered in this project", "▶", Outcome::Goto(Mode::Run)), + act("Ports", "Inspect listening ports & kill processes", "⚡", Outcome::Goto(Mode::Ports)), act("Theme", "Choose an accent color theme", "#", Outcome::Goto(Mode::Theme)), ], Mode::Bookmarks => { - self.cfg.bookmarks.iter().map(|b| { + let mut v: Vec = self.cfg.bookmarks.iter().map(|b| { let p = PathBuf::from(b); let name = p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| b.clone()); let cmd = format!("cd -- {}", tfs::shell_quote(b)); Action { path: Some(p), is_dir: true, ..act(&name, b, "*", Outcome::Emit(Cmd::run(cmd))) } - }).collect() + }).collect(); + for r in self.cfg.recent_dirs() { + let p = PathBuf::from(&r); + let name = p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| r.clone()); + let cmd = format!("cd -- {}", tfs::shell_quote(&r)); + v.push(Action { path: Some(p), is_dir: true, ..act(&name, &r, "↺", Outcome::Emit(Cmd::run(cmd))) }); + } + v }, Mode::Theme => { let current = self.cfg.ui.accent.trim().to_lowercase(); @@ -147,12 +156,12 @@ impl App { if self.git.is_none() { return vec![]; } let r = |n: &str, d: &str, c: &str| act(n, d, "⎇", Outcome::Emit(Cmd::run(c))); vec![ - act("Changed files", "Pick a file, then choose what to do", "⎇", Outcome::Goto(Mode::GitFiles)), + act("Changed files", "Stage, unstage, or inspect modified files", "⎇", Outcome::Goto(Mode::GitFiles)), + act("Branches", "Switch branches with commit log preview", "⎇", Outcome::Goto(Mode::GitBranches)), + act("Stashes", "Inspect and pop saved stashes", "⎇", Outcome::Goto(Mode::GitStashes)), r("Status", "Short status with branch", "git status -sb"), r("Diff", "Unstaged changes", "git diff"), - r("Branches", "All local and remote branches", "git branch -a"), r("Commits", "Last 20 commits", "git log --oneline -n 20"), - r("Stash list", "Saved stashes", "git stash list"), act("Pull", "Fast-forward only, with preview", "⎇", Outcome::Emit(Cmd::run("git pull --ff-only").previewed())), act("Push", "Push current branch, with preview", "⎇", Outcome::Emit(Cmd::run("git push").previewed())), ] @@ -161,10 +170,24 @@ impl App { let name = format!("{:<2} {}", st.trim(), p.strip_prefix(&self.cwd).unwrap_or(&p).display()); Action { path: Some(p.clone()), out: Outcome::Goto(Mode::Actions(p)), ..act(&name, &st, "·", Outcome::Goto(Mode::Menu)) } }).collect(), + Mode::GitBranches => git::branches(&self.cwd).into_iter().map(|(b, subj)| { + let target = b.strip_prefix("origin/").unwrap_or(&b); + let cmd = format!("git switch {}", tfs::shell_quote(target)); + act(&b, &subj, "⎇", Outcome::Emit(Cmd::run(cmd))) + }).collect(), + Mode::GitStashes => git::stashes(&self.cwd).into_iter().map(|(r, desc)| { + let cmd = format!("git stash pop {}", tfs::shell_quote(&r)); + act(&r, &desc, "⎇", Outcome::Emit(Cmd::run(cmd))) + }).collect(), Mode::Run => { let root = self.git.as_ref().map(|g| g.root.as_path()); run::discover(&self.cwd, root).into_iter().map(|r| act(&r.cmd, &r.source, "▶", Outcome::Emit(Cmd::run(r.cmd.clone())))).collect() } + Mode::Ports => sys::listening_ports().into_iter().map(|p| { + let name = format!(":{:<6} {}", p.port, p.proc_name); + let desc = format!("PID {}", p.pid); + act(&name, &desc, "⚡", Outcome::Emit(Cmd::run(format!("kill -9 {}", p.pid)))) + }).collect(), Mode::Actions(p) => { let q = self.rel(&p); let is_dir = p.is_dir(); @@ -306,6 +329,14 @@ impl App { if lines.is_empty() { lines = tfs::head(&p, 80); } Preview { title: a.name.trim().to_string(), lines } } + Mode::GitBranches => { + let lines = git::branch_log(&self.cwd, &a.name, 25); + Preview { title: format!("BRANCH: {}", a.name), lines } + } + Mode::GitStashes => { + let lines = git::stash_preview(&self.cwd, &a.name, 80); + Preview { title: format!("STASH: {}", a.name), lines } + } Mode::Files | Mode::Find => { let p = a.path.clone().unwrap(); let lines = if a.is_dir { tfs::list_dir(&p, self.show_hidden).into_iter().take(80).map(|e| if e.is_dir { format!("▸ {}/", e.name) } else { format!(" {}", e.name) }).collect() } else { tfs::head(&p, 80) }; @@ -475,6 +506,13 @@ impl App { KeyCode::Char(c) if !ctrl && self.searching => { self.query.push(c); self.refilter(); } KeyCode::Char('/') if self.mode != Mode::Menu => self.searching = true, KeyCode::Char('q') => self.quit = true, + KeyCode::Char('Q') if !self.searching => { + if self.cwd != self.start_cwd { + self.finish(Cmd::run(format!("cd -- {}", tfs::shell_quote(&self.cwd.to_string_lossy())))); + } else { + self.quit = true; + } + } KeyCode::Char('m') => { self.stack.clear(); self.mode = Mode::Menu; self.enter_mode(); } KeyCode::Char(' ') if !self.searching && self.mode == Mode::GitFiles => { if let Some(i) = self.current() { diff --git a/src/config.rs b/src/config.rs index e266cb2..51b6579 100644 --- a/src/config.rs +++ b/src/config.rs @@ -130,6 +130,23 @@ impl Config { let updated = format_bookmarks_toml(¤t, &self.bookmarks); let _ = std::fs::write(&p, updated); } + + pub fn recent_dirs(&self) -> Vec { + std::process::Command::new("zoxide") + .args(["query", "-l"]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .lines() + .map(|l| l.trim().to_string()) + .filter(|p| !p.is_empty() && !self.is_bookmarked(p) && std::path::Path::new(p).is_dir()) + .take(15) + .collect() + }) + .unwrap_or_default() + } } pub fn format_bookmarks_toml(current: &str, bookmarks: &[String]) -> String { diff --git a/src/fuzzy.rs b/src/fuzzy.rs index 6a64dda..e9d0dc2 100644 --- a/src/fuzzy.rs +++ b/src/fuzzy.rs @@ -1,12 +1,38 @@ -/// Subsequence fuzzy score. None = no match. Higher = better. +/// Subsequence fuzzy score across whitespace-separated tokens. None = no match. Higher = better. pub fn score(query: &str, text: &str) -> Option { + let tokens: Vec<&str> = query.split_whitespace().collect(); + if tokens.is_empty() { + return Some(0); + } + let mut total = 0i64; + for tok in tokens { + total += score_token(tok, text)?.0; + } + Some(total) +} + +/// Returns character indices in `text` matched by `query` tokens. +pub fn matched_indices(query: &str, text: &str) -> Vec { + let mut idx = Vec::new(); + for tok in query.split_whitespace() { + if let Some((_, hits)) = score_token(tok, text) { + idx.extend(hits); + } + } + idx.sort_unstable(); + idx.dedup(); + idx +} + +fn score_token(query: &str, text: &str) -> Option<(i64, Vec)> { let q: Vec = query.chars().map(lc).collect(); if q.is_empty() { - return Some(0); + return Some((0, Vec::new())); } let t: Vec = text.chars().collect(); let tl: Vec = t.iter().copied().map(lc).collect(); let (mut qi, mut s, mut prev_hit) = (0usize, 0i64, false); + let mut hits = Vec::with_capacity(q.len()); for i in 0..tl.len() { if qi < q.len() && tl[i] == q[qi] { s += 1; @@ -17,6 +43,7 @@ pub fn score(query: &str, text: &str) -> Option { s += 3; } prev_hit = true; + hits.push(i); qi += 1; } else { prev_hit = false; @@ -33,7 +60,7 @@ pub fn score(query: &str, text: &str) -> Option { if hay.starts_with(&joined) { s += 10; } - Some(s - (t.len() as i64 / 8)) + Some((s - (t.len() as i64 / 8), hits)) } fn lc(c: char) -> char { @@ -46,8 +73,10 @@ mod tests { #[test] fn subsequence_and_ranking() { assert!(score("gst", "git status").is_some()); + assert!(score("status git", "git status").is_some()); assert!(score("xyz", "git status").is_none()); assert!(score("app", "App.tsx") > score("app", "a_big_pipeline.ts")); assert_eq!(score("", "anything"), Some(0)); + assert_eq!(matched_indices("gt", "git"), vec![0, 2]); } } diff --git a/src/git.rs b/src/git.rs index c3559d4..f138f3a 100644 --- a/src/git.rs +++ b/src/git.rs @@ -101,6 +101,38 @@ pub fn diff_preview(cwd: &Path, path: &Path, max: usize) -> Vec { .unwrap_or_default() } +/// (branch_name, last_commit_subject) +pub fn branches(cwd: &Path) -> Vec<(String, String)> { + git(cwd, &["for-each-ref", "--sort=-committerdate", "refs/heads", "refs/remotes", "--format=%(refname:short)|%(subject)"]) + .unwrap_or_default() + .lines() + .filter(|l| !l.ends_with("/HEAD") && !l.starts_with("origin/HEAD")) + .take(50) + .filter_map(|l| l.split_once('|').map(|(b, s)| (b.trim().to_string(), s.trim().to_string()))) + .collect() +} + +pub fn branch_log(cwd: &Path, branch: &str, max: usize) -> Vec { + git(cwd, &["log", "--oneline", "-n", &max.to_string(), branch]) + .map(|s| s.lines().map(String::from).collect()) + .unwrap_or_default() +} + +/// (stash_ref, description) +pub fn stashes(cwd: &Path) -> Vec<(String, String)> { + git(cwd, &["stash", "list"]) + .unwrap_or_default() + .lines() + .filter_map(|l| l.split_once(": ").map(|(r, d)| (r.trim().to_string(), d.trim().to_string()))) + .collect() +} + +pub fn stash_preview(cwd: &Path, stash_ref: &str, max: usize) -> Vec { + git(cwd, &["stash", "show", "-p", stash_ref]) + .map(|s| s.lines().take(max).map(String::from).collect()) + .unwrap_or_default() +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/run.rs b/src/run.rs index 4bb7094..686243c 100644 --- a/src/run.rs +++ b/src/run.rs @@ -29,6 +29,7 @@ fn scoped(cwd: &Path, target_dir: &Path, cmd: impl Into) -> String { pub fn kinds(cwd: &Path, git_root: Option<&Path>) -> Vec<&'static str> { let mut k = vec![]; for (f, n) in [ + (".tman.toml", "tman"), ("Cargo.toml", "Rust"), ("package.json", "Node"), ("Makefile", "Make"), @@ -43,6 +44,7 @@ pub fn kinds(cwd: &Path, git_root: Option<&Path>) -> Vec<&'static str> { ("docker-compose.yml", "Docker Compose"), ("compose.yaml", "Docker Compose"), ("Dockerfile", "Docker"), + (".env", "Env"), (".git", "Git"), ] { if resolve(cwd, git_root, f).is_some() && !k.contains(&n) { @@ -56,6 +58,21 @@ pub fn kinds(cwd: &Path, git_root: Option<&Path>) -> Vec<&'static str> { pub fn discover(cwd: &Path, git_root: Option<&Path>) -> Vec { let mut v = vec![]; + // Custom project recipes (.tman.toml) + if let Some(dir) = resolve(cwd, git_root, ".tman.toml") { + if let Ok(s) = fs::read_to_string(dir.join(".tman.toml")) { + if let Ok(val) = toml::from_str::(&s) { + if let Some(scripts) = val.get("scripts").and_then(|t| t.as_table()) { + for (name, cmd_val) in scripts { + if let Some(c) = cmd_val.as_str() { + v.push(RunItem { cmd: scoped(cwd, dir, c), source: format!(".tman.toml [{name}]") }); + } + } + } + } + } + } + // Node.js (package.json) if let Some(dir) = resolve(cwd, git_root, "package.json") { if let Ok(s) = fs::read_to_string(dir.join("package.json")) { @@ -177,6 +194,20 @@ pub fn discover(cwd: &Path, git_root: Option<&Path>) -> Vec { } } + // .env inspector (masked preview) + if let Some(dir) = resolve(cwd, git_root, ".env") { + if let Ok(s) = fs::read_to_string(dir.join(".env")) { + let keys: Vec = s.lines() + .map(str::trim) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .filter_map(|l| l.split_once('=').map(|(k, _)| format!("{} = ***", k.trim()))) + .take(25) + .collect(); + let summary = if keys.is_empty() { ".env (empty)".into() } else { format!(".env ({} keys)\n{}", keys.len(), keys.join("\n")) }; + v.push(RunItem { cmd: scoped(cwd, dir, "${VISUAL:-${EDITOR:-vi}} .env"), source: summary }); + } + } + v } diff --git a/src/sys.rs b/src/sys.rs index 03b7d6c..ca73aa1 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -250,3 +250,54 @@ fn format_duration(secs: u64) -> String { format!("{mins}m") } } + +pub struct PortInfo { + pub port: String, + pub pid: String, + pub proc_name: String, +} + +pub fn listening_ports() -> Vec { + use std::collections::HashSet; + use std::process::Command; + let mut out = Vec::new(); + let mut seen = HashSet::new(); + + if let Ok(o) = Command::new("lsof").args(["-iTCP", "-sTCP:LISTEN", "-P", "-n"]).output() { + if o.status.success() { + for line in String::from_utf8_lossy(&o.stdout).lines().skip(1) { + let cols: Vec<&str> = line.split_whitespace().collect(); + if cols.len() >= 9 { + let proc_name = cols[0].to_string(); + let pid = cols[1].to_string(); + let addr = cols[cols.len() - 2]; + if let Some(port) = addr.rsplit(':').next() { + if seen.insert((port.to_string(), pid.clone())) { + out.push(PortInfo { port: port.to_string(), pid, proc_name }); + } + } + } + } + return out; + } + } + + if let Ok(o) = Command::new("ss").args(["-lptnH"]).output() { + if o.status.success() { + for line in String::from_utf8_lossy(&o.stdout).lines() { + let cols: Vec<&str> = line.split_whitespace().collect(); + if cols.len() >= 5 { + let local = cols[3]; + let port = local.rsplit(':').next().unwrap_or("").to_string(); + let proc_col = cols.get(5).copied().unwrap_or(""); + let proc_name = proc_col.split('"').nth(1).unwrap_or("process").to_string(); + let pid = proc_col.split("pid=").nth(1).and_then(|s| s.split(',').next()).unwrap_or("").to_string(); + if !port.is_empty() && !pid.is_empty() && seen.insert((port.clone(), pid.clone())) { + out.push(PortInfo { port, pid, proc_name }); + } + } + } + } + } + out +} diff --git a/src/ui.rs b/src/ui.rs index 093b5ee..d82a860 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -167,10 +167,20 @@ fn draw_list(f: &mut Frame, app: &mut App, area: Rect, acc: Color) { let items: Vec = app.filtered.iter().map(|&i| { let a = &app.items[i]; let name_style = if a.is_dir { Style::default().fg(acc) } else if a.icon == "!" { Style::default().fg(Color::Rgb(240, 100, 100)) } else { Style::default() }; - let mut sp = vec![Span::styled(format!("{} ", a.icon), Style::default().fg(DIM)), Span::styled(a.name.clone(), name_style)]; + let mut sp = vec![Span::styled(format!("{} ", a.icon), Style::default().fg(DIM))]; + if app.query.is_empty() { + sp.push(Span::styled(a.name.clone(), name_style)); + } else { + let hits = crate::fuzzy::matched_indices(&app.query, &a.name); + let hi = name_style.fg(acc).add_modifier(Modifier::BOLD); + for (idx, ch) in a.name.chars().enumerate() { + sp.push(Span::styled(ch.to_string(), if hits.binary_search(&idx).is_ok() { hi } else { name_style })); + } + } if wide && !a.desc.is_empty() { + let short = a.desc.lines().next().unwrap_or(""); let gap = pad.saturating_sub(a.name.chars().count()) + 2; - sp.push(Span::styled(format!("{}{}", " ".repeat(gap), a.desc), Style::default().fg(DIM))); + sp.push(Span::styled(format!("{}{}", " ".repeat(gap), short), Style::default().fg(DIM))); } ListItem::new(Line::from(sp)) }).collect(); @@ -201,7 +211,7 @@ fn footer(app: &App, acc: Color) -> Line<'static> { let keys: &[(&str, &str)] = if app.overlay.is_some() { &[("Enter", "Confirm"), ("E", "Edit"), ("Esc", "Cancel")] } else { match &app.mode { Mode::Menu => &[("↑↓", "Move"), ("Enter", "Open"), ("b", "Bookmark dir"), ("Esc", "Close")], - Mode::Files => &[("↑↓", "Move"), ("→", "Enter dir"), ("←", "Parent"), ("Enter", "cd / open"), ("b", "Bookmark"), ("Tab", "Actions"), ("/", "Search"), (".", "Hidden"), ("m", "Menu"), ("Esc", "Close")], + Mode::Files => &[("↑↓", "Move"), ("→", "Enter dir"), ("←", "Parent"), ("Enter", "cd / open"), ("Q", "cd & quit"), ("b", "Bookmark"), ("Tab", "Actions"), ("/", "Search"), (".", "Hidden"), ("Esc", "Close")], Mode::Bookmarks => &[("↑↓", "Move"), ("Enter", "Jump / cd"), ("d / x", "Delete pin"), ("←", "Back"), ("Esc", "Close")], Mode::History => &[("type", "Search"), ("↑↓", "Move"), ("Enter", "Run"), ("Tab", "Edit first"), ("←", "Back"), ("Esc", "Close")], Mode::Find => &[("type", "Search"), ("↑↓", "Move"), ("Enter", "cd / open"), ("Tab", "Actions"), ("←", "Back"), ("Esc", "Close")], From d8d36f4dfbded730b84770bfe451154df71680ce Mon Sep 17 00:00:00 2001 From: Ankit Kumar Date: Wed, 21 Oct 2026 22:09:03 +0530 Subject: [PATCH 5/9] docs(readme): add Arch, Fedora, and Ubuntu/Debian installation guides --- README.md | 91 ++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 63 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index ed360a8..b9503cc 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![Rust](https://img.shields.io/badge/rust-2021_edition-orange.svg)](https://www.rust-lang.org/) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/IamShreshth/Tman) -[![Shell](https://img.shields.io/badge/shell-zsh%20%7C%20bash-green.svg)](https://github.com/IamShreshth/Tman) +[![Shell](https://img.shields.io/badge/shell-zsh%20%7C%20bash%20%7C%20fish-green.svg)](https://github.com/IamShreshth/Tman) [![Dependencies](https://img.shields.io/badge/dependencies-zero_runtime-brightgreen.svg)](docs/requirements.txt)
@@ -41,57 +41,90 @@ Press `Ctrl+Shift+T` (or `Ctrl+Space`) anywhere in your shell, or run `tman`. ## Linux Installation -### Universal (All Distros) +### Universal One-Liner (All Distros) + +Automatically detects your architecture (`x86_64` / `aarch64`), libc (`glibc` / `musl`), downloads the pre-built binary (or builds via Cargo if needed), and configures your shell: ```bash curl -fsSL https://raw.githubusercontent.com/IamShreshth/Tman/main/files/install.sh | bash ``` -### Fedora +--- + +### 1. Arch Linux (Manjaro, EndeavourOS, CachyOS) ```bash -# Install Rust toolchain (required to build from source) -sudo dnf install -y cargo rust +# 1. Install build prerequisites & optional clipboard/desktop helpers +sudo pacman -S --needed --noconfirm base-devel git rust wl-clipboard xclip xdg-utils -# Install tman +# 2. Install tman (via installer script or Cargo) curl -fsSL https://raw.githubusercontent.com/IamShreshth/Tman/main/files/install.sh | bash +# OR build directly via Cargo: +# cargo install --git https://github.com/IamShreshth/Tman.git --locked -# Activate (add to ~/.bashrc or ~/.zshrc) -echo 'eval "$(tman init bash)"' >> ~/.bashrc -source ~/.bashrc +# 3. Activate in your shell: +source ~/.bashrc # Bash +# source ~/.zshrc # Zsh +# tman init fish | source # Fish (append to ~/.config/fish/config.fish) ``` -### Arch Linux +--- + +### 2. Fedora (RHEL, CentOS Stream, Nobara) ```bash -# Install Rust toolchain -sudo pacman -S --noconfirm rust +# 1. Install Rust toolchain & optional clipboard/desktop helpers +sudo dnf install -y gcc git cargo rust wl-clipboard xclip xdg-utils -# Install tman +# 2. Install tman (via installer script or Cargo) curl -fsSL https://raw.githubusercontent.com/IamShreshth/Tman/main/files/install.sh | bash +# OR build directly via Cargo: +# cargo install --git https://github.com/IamShreshth/Tman.git --locked -# Activate (add to ~/.bashrc or ~/.zshrc) -echo 'eval "$(tman init bash)"' >> ~/.bashrc -source ~/.bashrc +# 3. Activate in your shell: +source ~/.bashrc # Bash +# source ~/.zshrc # Zsh +# tman init fish | source # Fish (append to ~/.config/fish/config.fish) ``` -> **AUR users:** If an AUR package becomes available, you can also use `yay -S tman` or `paru -S tman`. +--- -### Ubuntu / Debian +### 3. Ubuntu / Debian (Linux Mint, Pop!_OS, Kali) ```bash -# Install Rust toolchain -sudo apt update && sudo apt install -y cargo rustc +# 1. Install build tools, Rust toolchain & optional clipboard/desktop helpers +sudo apt update && sudo apt install -y build-essential curl git cargo rustc wl-clipboard xclip xdg-utils -# Install tman +# 2. Install tman (via installer script or Cargo) curl -fsSL https://raw.githubusercontent.com/IamShreshth/Tman/main/files/install.sh | bash +# OR build directly via Cargo: +# cargo install --git https://github.com/IamShreshth/Tman.git --locked -# Activate (add to ~/.bashrc or ~/.zshrc) -echo 'eval "$(tman init bash)"' >> ~/.bashrc -source ~/.bashrc +# 3. Activate in your shell: +source ~/.bashrc # Bash +# source ~/.zshrc # Zsh +# tman init fish | source # Fish (append to ~/.config/fish/config.fish) ``` -> **Note on keybindings:** Most Linux terminal emulators intercept `Ctrl+Shift+T` (e.g., GNOME Terminal uses it to open a new tab). Use `Ctrl+Space` instead by setting `open = "ctrl-space"` in your tman config (`~/.config/tman/config.toml`). +> **Tip for older Ubuntu/Debian releases:** If the `apt` Rust version is older than `1.70`, install the latest toolchain via `rustup`: +> ```bash +> curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +> source "$HOME/.cargo/env" +> ``` + +--- + +### Manual Shell Hook Setup (If Installing via Cargo) + +If you installed via `cargo install` instead of `install.sh`, add the shell hook for your shell: + +| Shell | Command | +| :--- | :--- | +| **Bash** | `echo 'eval "$(tman init bash)"' >> ~/.bashrc && source ~/.bashrc` | +| **Zsh** | `echo 'eval "$(tman init zsh)"' >> ~/.zshrc && source ~/.zshrc` | +| **Fish** | `mkdir -p ~/.config/fish && echo 'tman init fish \| source' >> ~/.config/fish/config.fish && source ~/.config/fish/config.fish` | + +> **Note on Linux keybindings:** Most Linux terminal emulators intercept `Ctrl+Shift+T` to open a new tab. `tman` automatically binds **`Ctrl+Space`** alongside `Ctrl+Shift+T` so you can press `Ctrl+Space` out of the box on GNOME Terminal, Konsole, XFCE Terminal, Alacritty, Kitty, and WezTerm. --- @@ -115,8 +148,10 @@ source ~/.bashrc | `Left` / `h` / `Backspace` | Submenus | Navigate to parent directory / previous view | | `b` | Menu / Files | Bookmark current directory or highlighted folder | | `d` or `x` | Bookmarks | Delete highlighted bookmark | +| `Space` | Git Changed Files | Stage / unstage highlighted file in-place | +| `Q` | File Browser | Quit HUD and `cd` shell into the browsed directory | | `Tab` | Browser / Runners | Open action menu (`Open with app`, `Open file manager`, copy path) | -| `/` | Lists | Filter items with fuzzy search | +| `/` | Lists | Filter items with multi-word fuzzy search | | `.` | File Browser | Toggle hidden dotfiles | | `m` | Submenus | Return to main menu | | `Esc` | Any View | Clear search / close modal / exit HUD | @@ -131,10 +166,10 @@ source ~/.bashrc | Component | Requirement | | :--- | :--- | | **Operating System** | macOS (Apple Silicon & Intel) or Linux (x86_64, aarch64, musl/glibc) | -| **Supported Shells** | Zsh (v5.0+) or Bash (v4.0+) | +| **Supported Shells** | Zsh (v5.0+), Bash (v4.0+), or Fish (v3.4+) | | **Build Toolchain** | Rust 1.70+ and Cargo (only when building from source) | -See [docs/linux.md](docs/linux.md) for Linux-specific details and [docs/requirements.txt](docs/requirements.txt) for technical specifications. +See [linux.md](linux.md) for Linux-specific details and [docs/requirements.txt](docs/requirements.txt) for technical specifications. --- From f6d59d2515b6ed10519bf2f7fc5be5824d368459 Mon Sep 17 00:00:00 2001 From: Ankit Kumar Date: Wed, 21 Oct 2026 22:09:03 +0530 Subject: [PATCH 6/9] docs(readme): add Arch, Fedora, and Ubuntu/Debian installation guides --- src/sys.rs | 103 ++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 71 insertions(+), 32 deletions(-) diff --git a/src/sys.rs b/src/sys.rs index ca73aa1..e24df53 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -89,7 +89,11 @@ pub fn uptime() -> String { #[cfg(target_os = "macos")] pub fn battery() -> Option { let out = std::process::Command::new("pmset").arg("-g").arg("batt").output().ok()?; - let text = String::from_utf8_lossy(&out.stdout); + parse_pmset(&String::from_utf8_lossy(&out.stdout)) +} + +#[cfg(any(target_os = "macos", test))] +fn parse_pmset(text: &str) -> Option { for line in text.lines() { if line.contains('%') { let parts: Vec<&str> = line.split('\t').collect(); @@ -167,28 +171,36 @@ pub fn memory() -> Option { return None; } if let Ok(out) = std::process::Command::new("vm_stat").output() { - let text = String::from_utf8_lossy(&out.stdout); - let page_size: u64 = text.lines().next() - .and_then(|l| l.split("page size of ").nth(1)) - .and_then(|s| s.split_whitespace().next()) - .and_then(|n| n.parse().ok()) - .unwrap_or(4096); - let mut used_pages = 0u64; - for line in text.lines() { - if line.starts_with("Pages active:") || line.starts_with("Pages wired down:") || line.starts_with("Pages occupied by compressor:") { - if let Some(v) = line.split(':').nth(1).and_then(|s| s.trim().trim_end_matches('.').parse::().ok()) { - used_pages += v; - } - } - } - if used_pages > 0 { - let used = (used_pages * page_size).min(total); - return Some(format!("{} / {} ({:.0}%)", format_bytes(used), format_bytes(total), (used as f64 / total as f64) * 100.0)); + if let Some(s) = parse_vm_stat(&String::from_utf8_lossy(&out.stdout), total) { + return Some(s); } } Some(format_bytes(total)) } +#[cfg(any(target_os = "macos", test))] +fn parse_vm_stat(text: &str, total: u64) -> Option { + let page_size: u64 = text.lines().next() + .and_then(|l| l.split("page size of ").nth(1)) + .and_then(|s| s.split_whitespace().next()) + .and_then(|n| n.parse().ok()) + .unwrap_or(4096); + let mut used_pages = 0u64; + for line in text.lines() { + if line.starts_with("Pages active:") || line.starts_with("Pages wired down:") || line.starts_with("Pages occupied by compressor:") { + if let Some(v) = line.split(':').nth(1).and_then(|s| s.trim().trim_end_matches('.').parse::().ok()) { + used_pages += v; + } + } + } + if used_pages > 0 && total > 0 { + let used = (used_pages * page_size).min(total); + Some(format!("{} / {} ({:.0}%)", format_bytes(used), format_bytes(total), (used as f64 / total as f64) * 100.0)) + } else { + None + } +} + #[cfg(target_os = "linux")] pub fn memory() -> Option { if let Ok(s) = std::fs::read_to_string("/proc/meminfo") { @@ -265,20 +277,7 @@ pub fn listening_ports() -> Vec { if let Ok(o) = Command::new("lsof").args(["-iTCP", "-sTCP:LISTEN", "-P", "-n"]).output() { if o.status.success() { - for line in String::from_utf8_lossy(&o.stdout).lines().skip(1) { - let cols: Vec<&str> = line.split_whitespace().collect(); - if cols.len() >= 9 { - let proc_name = cols[0].to_string(); - let pid = cols[1].to_string(); - let addr = cols[cols.len() - 2]; - if let Some(port) = addr.rsplit(':').next() { - if seen.insert((port.to_string(), pid.clone())) { - out.push(PortInfo { port: port.to_string(), pid, proc_name }); - } - } - } - } - return out; + return parse_lsof(&String::from_utf8_lossy(&o.stdout)); } } @@ -301,3 +300,43 @@ pub fn listening_ports() -> Vec { } out } + +fn parse_lsof(text: &str) -> Vec { + use std::collections::HashSet; + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for line in text.lines().skip(1) { + let cols: Vec<&str> = line.split_whitespace().collect(); + if cols.len() >= 9 { + let proc_name = cols[0].to_string(); + let pid = cols[1].to_string(); + let addr = cols[cols.len() - 2]; + if let Some(port) = addr.rsplit(':').next() { + if seen.insert((port.to_string(), pid.clone())) { + out.push(PortInfo { port: port.to_string(), pid, proc_name }); + } + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn macos_vm_stat_and_pmset_and_lsof() { + let vm = "Mach Virtual Memory Statistics: (page size of 16384 bytes)\nPages free: 12000.\nPages active: 262144.\nPages inactive: 100000.\nPages wired down: 131072.\nPages occupied by compressor: 131072.\n"; + let total = 16 * 1024 * 1024 * 1024u64; // 16 GB + assert_eq!(parse_vm_stat(vm, total).as_deref(), Some("8.0 GB / 16.0 GB (50%)")); + + let pm = "Now drawing from 'Battery Power'\n -InternalBattery-0 (id=123)\t84%; discharging; 3:45 remaining present: true\n"; + assert_eq!(parse_pmset(pm).as_deref(), Some("84% (discharging)")); + + let lsof_out = "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\nnode 41230 user 21u IPv6 0x123456789 0t0 TCP *:3000 (LISTEN)\n"; + let ports = parse_lsof(lsof_out); + assert_eq!(ports.len(), 1); + assert_eq!((ports[0].port.as_str(), ports[0].pid.as_str(), ports[0].proc_name.as_str()), ("3000", "41230", "node")); + } +} From d819333a93c845c1910320ec43478d74241d44f1 Mon Sep 17 00:00:00 2001 From: Ankit Kumar Date: Wed, 21 Oct 2026 22:52:55 +0530 Subject: [PATCH 7/9] docs(readme): add macOS and Linux distro installation guides --- README.md | 29 +++++++++++++++++++ linux.md | 83 ------------------------------------------------------- 2 files changed, 29 insertions(+), 83 deletions(-) delete mode 100644 linux.md diff --git a/README.md b/README.md index b9503cc..1550895 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,35 @@ Press `Ctrl+Shift+T` (or `Ctrl+Space`) anywhere in your shell, or run `tman`. --- +## macOS Installation (Apple Silicon & Intel) + +### Option 1: Single-Step Installer (Recommended) +Automatically detects Apple Silicon (`arm64`) or Intel (`x86_64`), installs `tman`, signs the binary (`codesign`) so Gatekeeper never prompts, and configures `~/.zshrc`: + +```bash +curl -fsSL https://raw.githubusercontent.com/IamShreshth/Tman/main/files/install.sh | bash +source ~/.zshrc +``` + +### Option 2: Homebrew + Cargo (Build from Source) +```bash +# 1. Ensure Xcode Command Line Tools & Rust are installed +xcode-select --install 2>/dev/null || true +brew install rust # OR: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + +# 2. Install tman via Cargo +cargo install --git https://github.com/IamShreshth/Tman.git --locked + +# 3. Activate in your shell (Zsh is default on macOS): +echo 'eval "$(tman init zsh)"' >> ~/.zshrc && source ~/.zshrc +# Bash: echo 'eval "$(tman init bash)"' >> ~/.bash_profile && source ~/.bash_profile +# Fish: mkdir -p ~/.config/fish && echo 'tman init fish | source' >> ~/.config/fish/config.fish +``` + +> **Note on macOS integration:** `pbcopy` (clipboard) and `open` (Finder / default app opener) work out of the box on macOS with zero extra packages. For `Ctrl+Shift+T` in iTerm2, Warp, Kitty, Alacritty, or WezTerm, you can also use `Ctrl+Space` at any time. + +--- + ## Linux Installation ### Universal One-Liner (All Distros) diff --git a/linux.md b/linux.md deleted file mode 100644 index 456fd37..0000000 --- a/linux.md +++ /dev/null @@ -1,83 +0,0 @@ -# Linux Guide for tman - -`tman` runs natively on Linux (both `x86_64` and `aarch64` architectures) with zero external runtime dependencies. - ---- - -## 1. Installation - -### Quick Install (Single Command) -```bash -curl -fsSL https://raw.githubusercontent.com/IamShreshth/Tman/main/files/install.sh | bash -``` - -The installer: -- Auto-detects your architecture (`x86_64` or `aarch64`) and libc (glibc or musl for Alpine). -- Places the standalone binary in `~/.local/bin/tman` (or `~/.cargo/bin`). -- Adds the appropriate shell hook to your `~/.bashrc` or `~/.zshrc`. - -### Via Cargo -```bash -cargo install --git https://github.com/IamShreshth/Tman.git --locked -``` - -Then append the shell hook: -- **Bash**: - ```bash - echo 'eval "$(tman init bash)"' >> ~/.bashrc - source ~/.bashrc - ``` -- **Zsh**: - ```bash - echo 'eval "$(tman init zsh)"' >> ~/.zshrc - source ~/.zshrc - ``` - ---- - -## 2. Launching & Keyboard Shortcuts - -By default, `tman init` configures **both** `Ctrl+Shift+T` and `Ctrl+Space`: - -| Key | Notes | -| :--- | :--- | -| **`Ctrl+Space`** | **Recommended on Linux**: Terminal emulators (e.g., GNOME Terminal, Tilix, XFCE Terminal) reserve `Ctrl+Shift+T` for "Open New Tab". `Ctrl+Space` bypasses this conflict cleanly. | -| **`Ctrl+Shift+T`** | Works out of the box in terminals with CSI u support (Kitty, Foot, WezTerm, Alacritty) or if "New Tab" is remapped. | -| **`tman`** | Run `tman` directly in your shell anytime. | - ---- - -## 3. System Telemetry & Vitals - -On Linux, `tman` queries the Linux kernel and virtual filesystems directly: - -* **Power / Battery**: Queries `/sys/class/power_supply/BAT*` for percentage and charge status. Gracefully hidden on desktops/servers without a battery. -* **Memory (RAM)**: Reads total memory from `/proc/meminfo` with fallback to the `sysinfo(2)` syscall. -* **CPU Load**: Queries `libc::getloadavg` with fallback to `/proc/loadavg`. -* **Uptime**: Reads `/proc/uptime` with fallback to `sysinfo(2)`. -* **Disk Space**: Uses standard POSIX `statvfs(2)` with fragment and block size fallbacks. - ---- - -## 4. Clipboard & Desktop Integration - -* **Clipboard**: - * **Wayland**: Install `wl-clipboard` (`sudo apt install wl-clipboard` / `sudo pacman -S wl-clipboard`). - * **X11**: Install `xclip` (`sudo apt install xclip`) or `xsel`. -* **System Opener**: - * Pressing `Tab` on a file provides an **"Open with app"** action (`xdg-open`). - * Pressing `Tab` on a folder provides an **"Open file manager"** action (`xdg-open`). - ---- - -## 5. Testing with Docker - -To test `tman` inside a clean Linux container without affecting your host: - -```bash -# Run unit tests inside Linux -docker run --rm -v "$PWD":/usr/src/tman -w /usr/src/tman rust:slim cargo test - -# Launch the interactive HUD inside Linux -docker run --rm -it -v "$PWD":/usr/src/tman -w /usr/src/tman rust:slim cargo run -- ui -``` From c8e8a191e8991824344a8488041cc9867a849ae5 Mon Sep 17 00:00:00 2001 From: Ankit Kumar Date: Wed, 21 Oct 2026 22:53:03 +0530 Subject: [PATCH 8/9] feat(docs): update HUD simulator, replace emojis with SVG icons, and add OS tabs --- docs/app.js | 128 ++++++++++++++++++++---------- docs/index.html | 204 ++++++++++++++++++++++++++++++++++++------------ 2 files changed, 241 insertions(+), 91 deletions(-) diff --git a/docs/app.js b/docs/app.js index 4433bd3..49678c5 100644 --- a/docs/app.js +++ b/docs/app.js @@ -72,79 +72,109 @@ document.addEventListener('DOMContentLoaded', () => { const simList = document.getElementById('sim-list'); const dynTitle = document.getElementById('dyn-block-title'); const dynContent = document.getElementById('dyn-content'); + const rightPanelTitle = document.getElementById('right-panel-title'); + + const themeColors = { + cyan: '#46d7e6', + blue: '#5aa0ff', + green: '#50dc96', + magenta: '#c878ff', + peach: '#ffb48c' + }; const actionStates = { navigate: { - title: 'DIRECTORY CONTENTS', + panel: 'MISSION CONTROL', + title: '━━━ DIRECTORY CONTENTS ━━━━━━━━━━━━━━━', items: [ '▸ src/', '▸ tests/', - '· Cargo.toml', - '· Dockerfile', - '· README.md' + ' Cargo.toml', + ' Dockerfile', + ' README.md' ] }, bookmarks: { - title: 'PINNED BOOKMARKS', + panel: 'PREVIEW BOOKMARKS', + title: '━━━ PINNED & RECENT (ZOXIDE) ━━━━━━━━━', items: [ '* ~/projects/tman', '* ~/code/dotfiles', - '* ~/.config/nvim', - '* ~/work/backend-api' + '↺ ~/.config/nvim [recent]', + '↺ ~/work/backend-api [recent]' ] }, history: { - title: 'LIVE SHELL HISTORY (DEDUPLICATED)', + panel: 'PREVIEW HISTORY', + title: '━━━ LIVE SHELL HISTORY (DEDUPLICATED) ━', items: [ - '↺ cargo test --release', - '↺ git status -sb', - '↺ docker compose up -d', - '↺ nvim src/main.rs', - '↺ git push origin main' + '· cargo test --release', + '· git status -sb', + '· docker compose up -d', + '· nvim src/main.rs', + '· git push fork linux_ins' ] }, search: { - title: 'FUZZY MATCHES (QUERY: "sys")', + panel: 'PREVIEW SEARCH', + title: '━━━ GITIGNORE-AWARE FUZZY MATCHES ━━━━', items: [ - '· src/sys.rs (kernel telemetry)', - '· tests/test_sys.rs', - '· src/safety.rs' + '· src/sys.rs', + '· src/safety.rs', + '· src/git.rs', + '· files/install.sh' ] }, git: { - title: 'GIT ACTIONS & STATUS', + panel: 'PREVIEW GIT', + title: '━━━ INTERACTIVE GIT WORKFLOWS ━━━━━━━━', items: [ - '⎇ Status: 2 staged, 1 modified, 0 untracked', - '⎇ Commit: "feat: add linux support"', - '⎇ Diff: unstaged changes in src/sys.rs', - '⎇ Pull (fast-forward preview)', - '⎇ Push (origin/main)' + '⎇ Changed files (Space to stage/unstage)', + '⎇ Branches (git switch + log preview)', + '⎇ Stashes (git stash pop + diff)', + '+185 -79 in src/app.rs, src/git.rs' ] }, run: { - title: 'DISCOVERED WORKFLOW TARGETS', + panel: 'PREVIEW RUN', + title: '━━━ DISCOVERED PROJECT TARGETS ━━━━━━━', + items: [ + '▶ cargo build Cargo.toml', + '▶ cargo test Cargo.toml', + '▶ docker compose up -d Docker Compose', + '▶ ${EDITOR:-vi} .env .env (DATABASE_URL = ***)' + ] + }, + ports: { + panel: 'PREVIEW ACTIVE PORTS', + title: '━━━ LISTENING TCP PORTS (LSOF / SS) ━━', items: [ - '▶ cargo build', - '▶ cargo run -- ui', - '▶ cargo test', - '▶ cargo clippy', - '▶ docker build -t app .' + '◈ :3000 node PID 41230', + '◈ :5432 postgres PID 1892', + '◈ :8080 cargo-watch PID 50211' ] }, theme: { - title: 'ACCENT COLOR PALETTE', + panel: 'PREVIEW THEMES', + title: '━━━ CLICK A THEME TO PREVIEW ━━━━━━━━━', items: [ - '# Cyan (active default)', - '# Blue (Tokyo Night)', - '# Green (Matrix)', - '# Magenta (Synthwave)', - '# Peach (Catppuccin)' + '# cyan Futuristic high-tech cyan [active]', + '# blue Electric royal blue', + '# green Matrix / terminal emerald green', + '# magenta Cyberpunk neon magenta', + '# peach Soft warm aesthetic peach' ] } }; if (simList && dynTitle && dynContent) { const items = simList.querySelectorAll('.hud-item'); + let hudHovered = false; + + if (interactiveView) { + interactiveView.addEventListener('mouseenter', () => { hudHovered = true; }); + interactiveView.addEventListener('mouseleave', () => { hudHovered = false; }); + } function selectAction(item) { items.forEach(i => { @@ -152,13 +182,27 @@ document.addEventListener('DOMContentLoaded', () => { i.querySelector('.item-pointer').textContent = ' '; }); item.classList.add('active'); - item.querySelector('.item-pointer').textContent = '›'; + item.querySelector('.item-pointer').textContent = '❯'; const actionKey = item.getAttribute('data-action'); const state = actionStates[actionKey]; if (state) { + if (rightPanelTitle) rightPanelTitle.textContent = state.panel; dynTitle.textContent = state.title; - dynContent.innerHTML = state.items.map(text => `
${escapeHtml(text)}
`).join(''); + dynContent.innerHTML = state.items.map(text => { + const themeMatch = actionKey === 'theme' ? text.match(/^#\s+(\w+)/) : null; + const attr = themeMatch ? ` data-theme-color="${themeMatch[1]}" style="cursor:pointer"` : ''; + return `
${escapeHtml(text)}
`; + }).join(''); + + if (actionKey === 'theme') { + dynContent.querySelectorAll('[data-theme-color]').forEach(el => { + el.addEventListener('click', () => { + const c = themeColors[el.getAttribute('data-theme-color')]; + if (c) document.documentElement.style.setProperty('--cyan-primary', c); + }); + }); + } } } @@ -166,17 +210,19 @@ document.addEventListener('DOMContentLoaded', () => { item.addEventListener('click', () => selectAction(item)); }); - // Keyboard navigation (Up / Down) when focusing on the simulator + // Keyboard navigation (Up/Down or j/k) only when hovering or focusing the simulator window.addEventListener('keydown', (e) => { - if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + const focused = document.activeElement === interactiveView || (interactiveView && interactiveView.contains(document.activeElement)); + if (!hudHovered && !focused) return; + if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'j' || e.key === 'k') { const activeItem = simList.querySelector('.hud-item.active'); if (!activeItem) return; const all = Array.from(items); const idx = all.indexOf(activeItem); - if (e.key === 'ArrowDown' && idx < all.length - 1) { + if ((e.key === 'ArrowDown' || e.key === 'j') && idx < all.length - 1) { e.preventDefault(); selectAction(all[idx + 1]); - } else if (e.key === 'ArrowUp' && idx > 0) { + } else if ((e.key === 'ArrowUp' || e.key === 'k') && idx > 0) { e.preventDefault(); selectAction(all[idx - 1]); } diff --git a/docs/index.html b/docs/index.html index 8e87494..912576a 100644 --- a/docs/index.html +++ b/docs/index.html @@ -28,26 +28,30 @@ - +