add ais (rust fuzzy selector) and atproto plugin
This commit is contained in:
11
rust/ais/Cargo.toml
Normal file
11
rust/ais/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "ais"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "ai selector - unified fuzzy finder for shell"
|
||||
|
||||
[dependencies]
|
||||
|
||||
[[bin]]
|
||||
name = "ais"
|
||||
path = "src/main.rs"
|
||||
249
rust/ais/src/main.rs
Normal file
249
rust/ais/src/main.rs
Normal file
@@ -0,0 +1,249 @@
|
||||
// ais - ai selector
|
||||
// unified fuzzy finder: directories, files, history
|
||||
// usage: ais [cd|file|hist] or pipe: echo "a\nb" | ais
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::io::{Read, Write, stdout, stdin};
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
let mode = args.get(1).map(|s| s.as_str()).unwrap_or("cd");
|
||||
|
||||
let items = if !is_tty_stdin() {
|
||||
read_stdin_lines()
|
||||
} else {
|
||||
match mode {
|
||||
"cd" => get_dirs(),
|
||||
"file" | "f" => get_files(),
|
||||
"hist" | "h" => get_history(),
|
||||
_ => {
|
||||
eprintln!("usage: ais [cd|file|hist]");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(selected) = fuzzy_select(&items, mode) {
|
||||
print!("{}", selected);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_tty_stdin() -> bool {
|
||||
extern "C" {
|
||||
fn isatty(fd: i32) -> i32;
|
||||
}
|
||||
unsafe { isatty(0) != 0 }
|
||||
}
|
||||
|
||||
fn get_dirs() -> Vec<String> {
|
||||
let home = env::var("HOME").unwrap_or_default();
|
||||
let mut dirs = vec![];
|
||||
|
||||
// try zsh recent-dirs
|
||||
let chpwd = format!("{}/.local/share/zsh/recent-dirs", home);
|
||||
if let Ok(content) = std::fs::read_to_string(&chpwd) {
|
||||
for line in content.lines() {
|
||||
let path = line.trim().replace("$'", "").replace('\'', "");
|
||||
if !path.is_empty() && std::path::Path::new(&path).is_dir() {
|
||||
dirs.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fallback: home subdirs
|
||||
if dirs.is_empty() {
|
||||
if let Ok(entries) = std::fs::read_dir(&home) {
|
||||
for entry in entries.flatten() {
|
||||
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.starts_with('.') {
|
||||
dirs.push(entry.path().to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dirs.sort();
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
fn get_files() -> Vec<String> {
|
||||
let output = Command::new("find")
|
||||
.args([".", "-maxdepth", "3", "-not", "-path", "*/.git/*", "-type", "f"])
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(out) => String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.map(|l| l.strip_prefix("./").unwrap_or(l).to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect(),
|
||||
Err(_) => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn get_history() -> Vec<String> {
|
||||
let home = env::var("HOME").unwrap_or_default();
|
||||
let histfile = format!("{}/.zsh_history", home);
|
||||
|
||||
match std::fs::read(&histfile) {
|
||||
Ok(bytes) => {
|
||||
let content = String::from_utf8_lossy(&bytes);
|
||||
let mut seen = HashSet::new();
|
||||
let mut lines = vec![];
|
||||
for line in content.lines().rev() {
|
||||
let cmd = if let Some(pos) = line.find(';') {
|
||||
&line[pos + 1..]
|
||||
} else {
|
||||
line
|
||||
};
|
||||
let cmd = cmd.trim().to_string();
|
||||
if !cmd.is_empty() && seen.insert(cmd.clone()) {
|
||||
lines.push(cmd);
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
Err(_) => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn read_stdin_lines() -> Vec<String> {
|
||||
let mut buf = String::new();
|
||||
let _ = stdin().read_to_string(&mut buf);
|
||||
buf.lines()
|
||||
.map(|l| l.to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn fuzzy_match(item: &str, query: &str) -> bool {
|
||||
if query.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let lower = item.to_lowercase();
|
||||
query
|
||||
.split_whitespace()
|
||||
.all(|w| lower.contains(&w.to_lowercase()))
|
||||
}
|
||||
|
||||
fn fuzzy_select(items: &[String], label: &str) -> Option<String> {
|
||||
let mut query = String::new();
|
||||
let mut cursor: usize = 0;
|
||||
|
||||
// save terminal state and enter raw mode
|
||||
let _ = Command::new("stty")
|
||||
.args(["-echo", "raw"])
|
||||
.stdin(std::process::Stdio::inherit())
|
||||
.status();
|
||||
|
||||
let result = run_select_loop(items, label, &mut query, &mut cursor);
|
||||
|
||||
// restore terminal
|
||||
let _ = Command::new("stty")
|
||||
.args(["echo", "-raw"])
|
||||
.stdin(std::process::Stdio::inherit())
|
||||
.status();
|
||||
print!("\x1b[2J\x1b[H");
|
||||
let _ = stdout().flush();
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn run_select_loop(
|
||||
items: &[String],
|
||||
label: &str,
|
||||
query: &mut String,
|
||||
cursor: &mut usize,
|
||||
) -> Option<String> {
|
||||
loop {
|
||||
let filtered: Vec<&String> = items.iter().filter(|i| fuzzy_match(i, query)).collect();
|
||||
|
||||
if filtered.is_empty() {
|
||||
*cursor = 0;
|
||||
} else if *cursor >= filtered.len() {
|
||||
*cursor = filtered.len() - 1;
|
||||
}
|
||||
|
||||
// draw
|
||||
let mut out = stdout();
|
||||
let _ = write!(out, "\x1b[2J\x1b[H");
|
||||
let _ = write!(out, "\x1b[33m{}\x1b[0m> {}\r\n", label, query);
|
||||
let _ = write!(
|
||||
out,
|
||||
"\x1b[90m{} items\x1b[0m\r\n",
|
||||
filtered.len()
|
||||
);
|
||||
|
||||
let max_show = 20;
|
||||
for (i, item) in filtered.iter().enumerate().take(max_show) {
|
||||
if i == *cursor {
|
||||
let _ = write!(out, "\x1b[7m {}\x1b[0m\r\n", item);
|
||||
} else {
|
||||
let _ = write!(out, " {}\r\n", item);
|
||||
}
|
||||
}
|
||||
if filtered.len() > max_show {
|
||||
let _ = write!(
|
||||
out,
|
||||
" \x1b[90m+{} more\x1b[0m\r\n",
|
||||
filtered.len() - max_show
|
||||
);
|
||||
}
|
||||
let _ = out.flush();
|
||||
|
||||
// read key from /dev/tty (works even when stdin is piped)
|
||||
let mut tty = match std::fs::File::open("/dev/tty") {
|
||||
Ok(f) => f,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let mut buf = [0u8; 3];
|
||||
let n = tty.read(&mut buf).unwrap_or(0);
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
match buf[0] {
|
||||
b'\r' | b'\n' => {
|
||||
return filtered.get(*cursor).map(|s| s.to_string());
|
||||
}
|
||||
b'\x1b' => {
|
||||
if n >= 3 && buf[1] == b'[' {
|
||||
match buf[2] {
|
||||
b'A' => {
|
||||
if *cursor > 0 {
|
||||
*cursor -= 1;
|
||||
}
|
||||
}
|
||||
b'B' => *cursor += 1,
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
b'\x0e' => *cursor += 1, // C-n
|
||||
b'\x10' => { // C-p
|
||||
if *cursor > 0 {
|
||||
*cursor -= 1;
|
||||
}
|
||||
}
|
||||
b'\x03' => return None, // C-c
|
||||
b'\x7f' | b'\x08' => { // backspace
|
||||
query.pop();
|
||||
*cursor = 0;
|
||||
}
|
||||
c if c >= b' ' && c <= b'~' => { // printable
|
||||
query.push(c as char);
|
||||
*cursor = 0;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user