use std::path::PathBuf;
use std::process::{Command, Stdio};
use crate::agents::common::canonical_home;
use crate::cli::{
Field, FilterArgs, InteractiveArgs, ListProjectsArgs, ListProjectsResolvedArgs, MemoryArgs,
MemoryField, MemoryResolvedArgs, ProjectField, ResumeArgs, SearchArgs, ShowArgs, SortOrder,
};
use crate::memory;
use crate::output::{self, compute_column_widths, format_columns, sanitize_for_display};
use crate::pipeline;
use crate::projects;
use crate::remote;
use crate::resolver::{self, shell_quote};
const PREVIEW_SELECTORS: &[&str] = &["fzf", "sk"];
fn print_session_fields(
path: &str,
fields: &[Field],
query: &str,
search_mode: crate::cli::SearchMode,
) -> Result<(), String> {
if !query.is_empty() && fields.contains(&Field::Matched) {
match search_mode {
crate::cli::SearchMode::All => regex::bytes::Regex::new(&format!("(?iu){}", query))
.map(drop)
.map_err(|e| format!("Invalid regex '{}': {}", query, e))?,
crate::cli::SearchMode::Prompt => regex::Regex::new(&format!("(?i){}", query))
.map(drop)
.map_err(|e| format!("Invalid regex '{}': {}", query, e))?,
}
}
let pb = std::path::PathBuf::from(path);
let home = canonical_home();
crate::show::emit_session_meta_tsv(&pb, &home, fields, query, search_mode)
}
fn strip_shell_quote(s: &str) -> String {
if s.len() >= 2 && s.starts_with('\'') && s.ends_with('\'') {
s[1..s.len() - 1].replace("'\"'\"'", "'")
} else {
s.to_string()
}
}
fn resolve_selector(ia: &InteractiveArgs) -> String {
ia.selector
.clone()
.or_else(|| std::env::var("AH_SELECTOR").ok().filter(|s| !s.is_empty()))
.unwrap_or_else(|| "fzf".to_string())
}
fn use_preview(ia: &InteractiveArgs, selector: &str) -> bool {
let selector_bin = selector.rsplit('/').next().unwrap_or(selector);
!ia.no_preview && PREVIEW_SELECTORS.contains(&selector_bin)
}
fn fzf_version(selector: &str) -> Option<(u32, u32)> {
let output = std::process::Command::new(selector)
.arg("--version")
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
static VERSION_RE: std::sync::LazyLock<regex::Regex> =
std::sync::LazyLock::new(|| regex::Regex::new(r"(\d+)\.(\d+)").unwrap());
let caps = VERSION_RE.captures(&stdout)?;
Some((caps[1].parse().unwrap_or(0), caps[2].parse().unwrap_or(0)))
}
fn build_ltsv_input<KeyEsc, ValEsc>(
key_label: &str,
keys: &[String],
rows: &[Vec<String>],
field_names: &[String],
key_escape: KeyEsc,
val_escape: ValEsc,
) -> String
where
KeyEsc: Fn(&str) -> String,
ValEsc: Fn(&str) -> String,
{
let mut input = String::new();
for (i, key) in keys.iter().enumerate() {
input.push_str(&format!("{}:{}", key_label, key_escape(key)));
for (j, val) in rows[i].iter().enumerate() {
input.push('\t');
input.push_str(&format!("{}:{}", field_names[j], val_escape(val)));
}
input.push('\n');
}
input
}
pub fn run_log(args: &SearchArgs, ia: &InteractiveArgs, filter: &FilterArgs) -> Result<(), String> {
let ltsv = args.ltsv();
let selector = resolve_selector(ia);
let preview = use_preview(ia, &selector);
let default_display = || {
vec![
Field::Agent,
Field::Project,
Field::ModifiedAt,
Field::Title,
]
};
let output_fields = match args.common.parse_fields()? {
Some(fields) if fields.is_empty() => {
return Err(
"-o/--fields requires at least one field name (got empty list)".to_string(),
);
}
Some(fields) => Some(crate::cli::hoist_path_first(fields)),
None => None,
};
let display_fields = ia
.parse_display_fields(false)?
.unwrap_or_else(default_display);
let sort_field = args.sort_field()?;
let mut resolve_fields = vec![Field::Path, Field::Id];
for f in &display_fields {
if *f != Field::Path && *f != Field::Id {
resolve_fields.push(*f);
}
}
let _ = &output_fields;
if !resolve_fields.contains(&sort_field) {
resolve_fields.push(sort_field);
}
let query = filter.query.clone().unwrap_or_default();
let result = pipeline::run_pipeline(&pipeline::PipelineParams {
resolve_fields: resolve_fields.clone(),
resolve_opts: resolver::ResolveOpts::default_with_title_limit(30),
filters: filter.to_filters(),
since: filter.since_time()?,
until: filter.until_time()?,
query,
search_mode: filter.search_mode(),
sort_field,
sort_order: SortOrder::Desc,
collect_limit: filter.limit,
running: filter.running,
require_resume_cmd: false,
})?;
let mut sessions = result.sessions;
remote::merge_into_sessions(
&mut sessions,
filter,
&resolve_fields,
sort_field,
SortOrder::Desc,
)?;
if sessions.is_empty() {
return Err("No sessions found.".to_string());
}
let visible_fields: Vec<&Field> = display_fields
.iter()
.filter(|f| **f != Field::Path)
.collect();
let keys: Vec<String> = sessions
.iter()
.map(|s| shell_quote(s.fields.get(&Field::Path).map(|v| v.as_str()).unwrap_or("")))
.collect();
let rows: Vec<Vec<String>> = sessions
.iter()
.map(|s| {
visible_fields
.iter()
.map(|f| sanitize_for_display(s.fields.get(f).map(|v| v.as_str()).unwrap_or("")))
.collect()
})
.collect();
let (input, with_nth) = if ltsv {
let field_names: Vec<String> = visible_fields
.iter()
.map(|f| f.name().to_string())
.collect();
let mut inp = String::new();
for (i, key) in keys.iter().enumerate() {
inp.push_str(&format!("path:{}", output::escape_tsv_lossless(key)));
inp.push('\t');
let marker = if sessions[i]
.fields
.get(&Field::Running)
.is_some_and(|v| v == "true")
{
"\x1b[32mR\x1b[0m "
} else {
" "
};
inp.push_str(marker);
for (j, val) in rows[i].iter().enumerate() {
inp.push_str(&format!("{}:{}", field_names[j], output::escape_tsv(val)));
if j < rows[i].len() - 1 {
inp.push('\t');
}
}
inp.push('\n');
}
let count = visible_fields.len();
(inp, format!("--with-nth=2..{}", count + 2))
} else {
let widths = compute_column_widths(&rows);
let colors: Vec<&str> = visible_fields
.iter()
.map(|f| output::field_color(f))
.collect();
let mut inp = String::new();
for (i, key) in keys.iter().enumerate() {
inp.push_str(key);
inp.push('\t');
let marker = if sessions[i]
.fields
.get(&Field::Running)
.is_some_and(|v| v == "true")
{
"\x1b[32mR\x1b[0m "
} else {
" "
};
inp.push_str(marker);
inp.push_str(&format_columns(&rows[i], &widths, &colors));
inp.push('\n');
}
(inp, "--with-nth=2..".to_string())
};
let mut selector_args: Vec<String> = vec![
"--ansi".to_string(),
"--no-sort".to_string(),
"--delimiter=\t".to_string(),
with_nth,
];
if preview {
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("ah"));
let exe_quoted = shell_quote(&exe.to_string_lossy());
if ltsv {
selector_args.push(format!(
"--preview=p={{r1}}; p=${{p#path:}}; p=$(printf '%b' \"$p\"); {} show --color \"$p\"",
exe_quoted
));
} else {
selector_args.push(format!("--preview={} show --color {{r1}}", exe_quoted));
}
selector_args.push("--preview-window=right:60%:wrap".to_string());
push_preview_search_binds(&mut selector_args, &exe_quoted, ltsv, "path", &selector);
}
let selected = match run_selector(&selector, &selector_args, &input)? {
Some(s) => s,
None => return Ok(()),
};
let first = selected.split('\t').next().unwrap_or("");
let quoted = first.strip_prefix("path:").unwrap_or(first);
let path = if ltsv {
strip_shell_quote(&output::unescape_tsv(quoted))
} else {
strip_shell_quote(quoted)
};
if path.is_empty() {
return Ok(());
}
match &output_fields {
Some(fields) => {
if let Some((remote_def, remote_path)) = remote::parse_remote_path(&path) {
remote::run_remote_show_meta(remote_def, remote_path, fields, filter)?;
} else {
print_session_fields(
&path,
fields,
&filter.query.clone().unwrap_or_default(),
filter.search_mode(),
)?;
}
}
None => println!("{}", path),
}
Ok(())
}
pub fn run_project(
args: &ListProjectsArgs,
ia: &InteractiveArgs,
filter: &FilterArgs,
) -> Result<(), String> {
let ltsv = args.ltsv();
let selector = resolve_selector(ia);
let preview = use_preview(ia, &selector);
let resolved = ListProjectsResolvedArgs::from_interactive(args)?;
if !filter.remote.is_empty() {
eprintln!(
"Warning: --remote is ignored in interactive project mode (use non-interactive listing)"
);
}
let records = projects::build_project_records(&resolved, filter)?;
let display_tail: Vec<ProjectField> = resolved
.fields
.iter()
.copied()
.filter(|f| *f != ProjectField::Cwd)
.collect();
let keys: Vec<String> = records
.iter()
.map(|r| {
r.get(&ProjectField::Cwd)
.map(|s| s.as_str())
.unwrap_or("")
.to_string()
})
.collect();
let rows: Vec<Vec<String>> = records
.iter()
.map(|r| {
display_tail
.iter()
.map(|pf| sanitize_for_display(r.get(pf).map(|s| s.as_str()).unwrap_or("")))
.collect()
})
.collect();
let (input, with_nth) = if ltsv {
let field_names: Vec<String> = display_tail.iter().map(|f| f.name().to_string()).collect();
let inp = build_ltsv_input(
"cwd",
&keys,
&rows,
&field_names,
output::escape_tsv_lossless,
output::escape_tsv,
);
let count = display_tail.len();
let wn = if count == 0 {
"--with-nth=1".to_string()
} else {
format!("--with-nth=2..{}", count + 1)
};
(inp, wn)
} else {
let widths = compute_column_widths(&rows);
let colors: Vec<&str> = display_tail
.iter()
.map(|f| output::project_field_color(f))
.collect();
let mut inp = String::new();
for (i, key) in keys.iter().enumerate() {
inp.push_str(key);
inp.push('\t');
inp.push_str(&format_columns(&rows[i], &widths, &colors));
inp.push('\n');
}
(inp, "--with-nth=2..".to_string())
};
let mut selector_args: Vec<String> = vec![
"--ansi".to_string(),
"--no-sort".to_string(),
"--delimiter=\t".to_string(),
with_nth,
];
if preview {
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("ah"));
let exe_quoted = shell_quote(&exe.to_string_lossy());
if ltsv {
selector_args.push(format!(
"--preview=p={{1}}; p=${{p#cwd:}}; p=$(printf '%b' \"$p\"); {} log -d \"$p\" -o agent,modified_at,title --color",
exe_quoted
));
} else {
selector_args.push(format!(
"--preview={} log -d {{1}} -o agent,modified_at,title --color",
exe_quoted
));
}
selector_args.push("--preview-window=right:60%:wrap".to_string());
}
let selected = match run_selector(&selector, &selector_args, &input)? {
Some(s) => s,
None => return Ok(()),
};
let first = selected.split('\t').next().unwrap_or("");
let cwd_raw = first.strip_prefix("cwd:").unwrap_or(first);
let cwd = if ltsv {
output::unescape_tsv(cwd_raw)
} else {
cwd_raw.to_string()
};
if cwd.is_empty() {
return Ok(());
}
println!("{}", cwd);
Ok(())
}
pub fn run_show(args: &ShowArgs, ia: &InteractiveArgs, filter: &FilterArgs) -> Result<(), String> {
let selector = resolve_selector(ia);
let preview = use_preview(ia, &selector);
let meta_fields = args.meta_fields()?;
let default_display = || {
vec![
Field::Agent,
Field::Project,
Field::ModifiedAt,
Field::Title,
]
};
let display_fields = ia
.parse_display_fields(true)?
.unwrap_or_else(default_display);
let mut resolve_fields = vec![Field::Path, Field::Id, Field::ModifiedAt];
for f in &display_fields {
if !resolve_fields.contains(f) {
resolve_fields.push(*f);
}
}
let _ = &meta_fields;
let query = filter.query.clone().unwrap_or_default();
let resolve_opts =
resolver::ResolveOpts::new(&query, 500, 30).with_search_mode(filter.search_mode());
let result = pipeline::run_pipeline(&pipeline::PipelineParams {
resolve_fields: resolve_fields.clone(),
resolve_opts,
filters: filter.to_filters(),
since: filter.since_time()?,
until: filter.until_time()?,
query,
search_mode: filter.search_mode(),
sort_field: Field::ModifiedAt,
sort_order: SortOrder::Desc,
collect_limit: filter.limit,
running: filter.running,
require_resume_cmd: false,
})?;
let mut sessions = result.sessions;
remote::merge_into_sessions(
&mut sessions,
filter,
&resolve_fields,
Field::ModifiedAt,
SortOrder::Desc,
)?;
if sessions.is_empty() {
return Err("No sessions found.".to_string());
}
let visible_fields: Vec<&Field> = display_fields
.iter()
.filter(|f| **f != Field::Path)
.collect();
let keys: Vec<String> = sessions
.iter()
.map(|s| shell_quote(s.fields.get(&Field::Path).map(|v| v.as_str()).unwrap_or("")))
.collect();
let rows: Vec<Vec<String>> = sessions
.iter()
.map(|s| {
visible_fields
.iter()
.map(|f| sanitize_for_display(s.fields.get(f).map(|v| v.as_str()).unwrap_or("")))
.collect()
})
.collect();
let widths = compute_column_widths(&rows);
let colors: Vec<&str> = visible_fields
.iter()
.map(|f| output::field_color(f))
.collect();
let mut input = String::new();
for (i, key) in keys.iter().enumerate() {
input.push_str(key);
input.push('\t');
let marker = if sessions[i]
.fields
.get(&Field::Running)
.is_some_and(|v| v == "true")
{
"\x1b[32mR\x1b[0m "
} else {
" "
};
input.push_str(marker);
input.push_str(&format_columns(&rows[i], &widths, &colors));
input.push('\n');
}
let mut selector_args: Vec<String> = vec![
"--ansi".to_string(),
"--no-sort".to_string(),
"--delimiter=\t".to_string(),
"--with-nth=2..".to_string(),
];
if preview {
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("ah"));
let exe_quoted = shell_quote(&exe.to_string_lossy());
selector_args.push(format!("--preview={} show --color {{r1}}", exe_quoted));
selector_args.push("--preview-window=right:60%:wrap".to_string());
push_preview_search_binds(&mut selector_args, &exe_quoted, false, "path", &selector);
}
let selected = match run_selector(&selector, &selector_args, &input)? {
Some(s) => s,
None => return Ok(()),
};
let first = selected.split('\t').next().unwrap_or("");
let path_str = strip_shell_quote(first);
if path_str.is_empty() {
return Ok(());
}
if let Some(ref fields) = meta_fields {
if let Some((remote_def, remote_path)) = remote::parse_remote_path(&path_str) {
return remote::run_remote_show_meta(remote_def, remote_path, fields, filter);
}
let query = filter.query.clone().unwrap_or_default();
return print_session_fields(&path_str, fields, &query, filter.search_mode());
}
let show_args = ShowArgs::with_session(args.head, Some(path_str), args.highlight.clone());
crate::show::run(show_args, filter)
}
pub fn run_resume(
args: &ResumeArgs,
ia: &InteractiveArgs,
filter: &FilterArgs,
) -> Result<(), String> {
use crate::agents;
use crate::resume;
use std::fs;
use std::time::SystemTime;
let selector = resolve_selector(ia);
let preview = use_preview(ia, &selector);
let home = canonical_home();
let display_fields = args.common.parse_fields()?.unwrap_or_else(|| {
vec![
Field::Agent,
Field::Project,
Field::ModifiedAt,
Field::Title,
]
});
let mut resolve_fields = vec![Field::Path, Field::Id, Field::ResumeCmd, Field::ModifiedAt];
for f in &display_fields {
if !resolve_fields.contains(f) {
resolve_fields.push(*f);
}
}
let result = pipeline::run_pipeline(&pipeline::PipelineParams {
resolve_fields: resolve_fields.clone(),
resolve_opts: resolver::ResolveOpts::default_with_title_limit(30),
filters: filter.to_filters(),
since: filter.since_time()?,
until: filter.until_time()?,
query: filter.query.clone().unwrap_or_default(),
search_mode: filter.search_mode(),
sort_field: Field::ModifiedAt,
sort_order: SortOrder::Desc,
collect_limit: filter.limit,
running: filter.running,
require_resume_cmd: true,
})?;
let mut sessions = result.sessions;
remote::merge_into_sessions(
&mut sessions,
filter,
&resolve_fields,
Field::ModifiedAt,
SortOrder::Desc,
)?;
if sessions.is_empty() {
return Err("No resumable sessions found.".to_string());
}
let visible_fields: Vec<&Field> = display_fields
.iter()
.filter(|f| **f != Field::Path)
.collect();
let keys: Vec<String> = sessions
.iter()
.map(|s| shell_quote(s.fields.get(&Field::Path).map(|v| v.as_str()).unwrap_or("")))
.collect();
let rows: Vec<Vec<String>> = sessions
.iter()
.map(|s| {
visible_fields
.iter()
.map(|f| sanitize_for_display(s.fields.get(f).map(|v| v.as_str()).unwrap_or("")))
.collect()
})
.collect();
let ltsv = args.ltsv;
let (input, with_nth) = if ltsv {
let field_names: Vec<String> = visible_fields
.iter()
.map(|f| f.name().to_string())
.collect();
let inp = build_ltsv_input(
"path",
&keys,
&rows,
&field_names,
output::escape_tsv_lossless,
output::escape_tsv,
);
let count = visible_fields.len();
(inp, format!("--with-nth=2..{}", count + 1))
} else {
let widths = compute_column_widths(&rows);
let colors: Vec<&str> = visible_fields
.iter()
.map(|f| output::field_color(f))
.collect();
let mut inp = String::new();
for (i, key) in keys.iter().enumerate() {
inp.push_str(key);
inp.push('\t');
let marker = if sessions[i]
.fields
.get(&Field::Running)
.is_some_and(|v| v == "true")
{
"\x1b[32mR\x1b[0m "
} else {
" "
};
inp.push_str(marker);
inp.push_str(&format_columns(&rows[i], &widths, &colors));
inp.push('\n');
}
(inp, "--with-nth=2..".to_string())
};
let mut selector_args: Vec<String> = vec![
"--ansi".to_string(),
"--no-sort".to_string(),
"--delimiter=\t".to_string(),
with_nth,
];
if preview {
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("ah"));
let exe_quoted = shell_quote(&exe.to_string_lossy());
if ltsv {
selector_args.push(format!(
"--preview=p={{r1}}; p=${{p#path:}}; p=$(printf '%b' \"$p\"); {} show --color \"$p\"",
exe_quoted
));
} else {
selector_args.push(format!("--preview={} show --color {{r1}}", exe_quoted));
}
selector_args.push("--preview-window=right:60%:wrap".to_string());
push_preview_search_binds(&mut selector_args, &exe_quoted, ltsv, "path", &selector);
}
let selected = match run_selector(&selector, &selector_args, &input)? {
Some(s) => s,
None => return Ok(()),
};
let first = selected.split('\t').next().unwrap_or("");
let quoted = first.strip_prefix("path:").unwrap_or(first);
let path_str = if ltsv {
strip_shell_quote(&output::unescape_tsv(quoted))
} else {
strip_shell_quote(quoted)
};
if path_str.is_empty() {
return Ok(());
}
if let Some((remote_def, remote_ref)) = remote::parse_remote_path(&path_str) {
if args.print {
println!(
"{}",
remote::format_remote_resume_command(remote_def, remote_ref, &args.extra_args,)
);
return Ok(());
}
remote::exec_remote_resume(remote_def, remote_ref, args);
}
let path = PathBuf::from(&path_str);
let plugin = agents::find_plugin_for_path(&path);
let mtime = fs::metadata(&path)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
let fields = resolver::resolve_fields(
&path,
plugin,
mtime,
&home,
&[Field::ResumeCmd],
&Default::default(),
);
let cmd = fields
.get(&Field::ResumeCmd)
.map(|v| v.as_str())
.unwrap_or("");
if cmd.is_empty() {
return Err("No resume command available for this session.".to_string());
}
let full_cmd = if args.extra_args.is_empty() {
cmd.to_string()
} else {
let extra = args
.extra_args
.iter()
.map(|a| shell_quote(a))
.collect::<Vec<_>>()
.join(" ");
format!("{} {}", cmd, extra)
};
if args.print {
println!("{}", full_cmd);
return Ok(());
}
resume::exec_resume(&full_cmd);
}
fn push_preview_search_binds(
selector_args: &mut Vec<String>,
exe_quoted: &str,
ltsv: bool,
path_prefix: &str,
selector: &str,
) {
let selector_bin = selector.rsplit('/').next().unwrap_or(selector);
if selector_bin != "fzf" {
return;
}
let Some(version) = fzf_version(selector) else {
return;
};
if version < (0, 62) {
return;
}
let (prefix, path_ref) = if ltsv {
(
format!(
"p={{r1}}; p=${{p#{}:}}; p=$(printf '%b' \"$p\"); ",
path_prefix
),
"\"$p\"",
)
} else {
(String::new(), "{r1}")
};
if let Some(pos) = selector_args
.iter()
.position(|a| a.starts_with("--preview="))
{
selector_args[pos] = format!(
"--preview={}{} show --color --highlight=\"$FZF_QUERY\" {}",
prefix, exe_quoted, path_ref
);
}
let scroll_transform = format!(
"{}if [ -z \"$FZF_QUERY\" ]; then N=0; else N=`{} show {} | grep -Fni -m1 -- \"$FZF_QUERY\" | cut -d: -f1`; test -n \"$N\" || N=0; fi; echo \"change-preview-window(+$N)\"",
prefix, exe_quoted, path_ref
);
let transform_action = if version >= (0, 63) {
"bg-transform"
} else {
"transform"
};
selector_args.push(format!(
"--bind=change:refresh-preview+{}({})",
transform_action, scroll_transform
));
selector_args.push(
"--bind=ctrl-s:transform~if [ \"$FZF_INPUT_STATE\" = disabled ]; then echo 'enable-search+change-preview-window(+0)+transform-header(printf %s \"$FZF_HEADER\" | grep -Fv \"search off — ctrl-s to filter\")'; else echo 'disable-search+transform-header(if [ -n \"$FZF_HEADER\" ]; then printf %s\\n \"$FZF_HEADER\"; fi; printf \"\\033[33m[search off — ctrl-s to filter]\\033[0m\")'; fi~".to_string()
);
}
pub fn run_memory(
args: &MemoryArgs,
ia: &InteractiveArgs,
filter: &FilterArgs,
) -> Result<(), String> {
let selector = resolve_selector(ia);
let preview = use_preview(ia, &selector);
let resolved = MemoryResolvedArgs::from_args_interactive(args)?;
if !filter.remote.is_empty() {
eprintln!(
"Warning: --remote is ignored in interactive memory mode (use non-interactive listing)"
);
}
let records = memory::build_memory_records(&resolved, filter)?;
let display_fields: Vec<MemoryField> = resolved
.fields
.iter()
.copied()
.filter(|f| *f != MemoryField::Path)
.collect();
let home = canonical_home();
let home_str = home.to_string_lossy();
let keys: Vec<String> = records
.iter()
.map(|r| {
let p = r.get(&MemoryField::Path).map(|s| s.as_str()).unwrap_or("");
if let Some(rest) = p.strip_prefix('~') {
format!("{}{}", home_str, rest)
} else {
p.to_string()
}
})
.collect();
let rows: Vec<Vec<String>> = records
.iter()
.map(|r| {
display_fields
.iter()
.map(|f| sanitize_for_display(r.get(f).map(|s| s.as_str()).unwrap_or("")))
.collect()
})
.collect();
let widths = compute_column_widths(&rows);
let colors: Vec<&str> = display_fields
.iter()
.map(|f| output::memory_field_color(f))
.collect();
let mut input = String::new();
for (i, key) in keys.iter().enumerate() {
input.push_str(key);
input.push('\t');
input.push_str(&format_columns(&rows[i], &widths, &colors));
input.push('\n');
}
let mut selector_args: Vec<String> = vec![
"--ansi".to_string(),
"--no-sort".to_string(),
"--delimiter=\t".to_string(),
"--with-nth=2..".to_string(),
];
if preview {
selector_args.push("--preview=cat {1}".to_string());
selector_args.push("--preview-window=right:60%:wrap".to_string());
}
let selected = match run_selector(&selector, &selector_args, &input)? {
Some(s) => s,
None => return Ok(()),
};
let path = selected.split('\t').next().unwrap_or("");
if path.is_empty() {
return Ok(());
}
println!("{}", path);
Ok(())
}
fn run_selector(selector: &str, args: &[String], input: &str) -> Result<Option<String>, String> {
let mut child = Command::new(selector)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.map_err(|e| format!("{}: {}", selector, e))?;
if let Some(ref mut stdin) = child.stdin {
use std::io::Write;
let _ = stdin.write_all(input.as_bytes());
}
drop(child.stdin.take());
let result = child
.wait_with_output()
.map_err(|e| format!("{}: {}", selector, e))?;
if !result.status.success() {
match result.status.code() {
Some(1) | Some(130) => return Ok(None),
Some(code) => return Err(format!("{} exited with status {}", selector, code)),
None => return Err(format!("{} terminated by signal", selector)),
}
}
let line = String::from_utf8_lossy(&result.stdout);
let line = line.trim().to_string();
if line.is_empty() {
return Ok(None);
}
Ok(Some(line))
}
#[cfg(test)]
#[cfg(unix)]
mod tests {
use super::*;
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
fn fake_fzf(dir: &std::path::Path, banner: &str, exit_code: i32) -> String {
let path = dir.join("fzf");
{
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "#!/bin/sh\necho '{}'\nexit {}", banner, exit_code).unwrap();
}
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut last_err = None;
for _ in 0..100 {
match std::process::Command::new(&path).arg("--version").output() {
Ok(_) => {
last_err = None;
break;
}
Err(e) => {
last_err = Some(e);
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
}
if let Some(e) = last_err {
panic!("fake fzf never became executable: {e}");
}
path.to_string_lossy().into_owned()
}
fn binds_for(banner: &str) -> Vec<String> {
binds_for_exit(banner, 0)
}
fn binds_for_exit(banner: &str, exit_code: i32) -> Vec<String> {
let dir = tempfile::tempdir().unwrap();
let selector = fake_fzf(dir.path(), banner, exit_code);
let mut args = vec!["--preview='ah' show --color {r1}".to_string()];
push_preview_search_binds(&mut args, "'ah'", false, "path", &selector);
args
}
fn change_bind(args: &[String]) -> &String {
args.iter()
.find(|a| a.starts_with("--bind=change:"))
.unwrap_or_else(|| panic!("no change bind in {args:?}"))
}
#[test]
fn preview_search_uses_bg_transform_on_fzf_063_and_later() {
let args = binds_for("0.63.0 (test)");
assert!(
change_bind(&args).contains("refresh-preview+bg-transform("),
"{args:?}"
);
}
#[test]
fn preview_search_falls_back_to_transform_on_fzf_062() {
let args = binds_for("0.62.0 (test)");
let bind = change_bind(&args);
assert!(bind.contains("refresh-preview+transform("), "{bind}");
assert!(!bind.contains("bg-transform"), "{bind}");
}
#[test]
fn preview_search_skipped_on_fzf_before_062() {
let args = binds_for("0.61.3 (test)");
assert!(!args.iter().any(|a| a.starts_with("--bind=")), "{args:?}");
}
#[test]
fn preview_search_skipped_when_version_check_fails() {
let args = binds_for_exit("0.70.0 (test)", 1);
assert!(!args.iter().any(|a| a.starts_with("--bind=")), "{args:?}");
}
#[test]
fn preview_search_parses_prefixed_version_banner() {
let args = binds_for("fzf 0.64.0 (debian)");
assert!(
change_bind(&args).contains("refresh-preview+bg-transform("),
"{args:?}"
);
}
}