Compare commits

..

No commits in common. "main" and "0.9.1" have entirely different histories.
main ... 0.9.1

6 changed files with 178 additions and 219 deletions

View File

@ -4,20 +4,6 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [0.10.0] - 2026-03-05
### Added
- fzf preview panel: hovering over a project shows its `README.md` in the right 40% of the screen
- Uses `glow` for rendered markdown when available, falls back to `cat`
- `CLICOLOR_FORCE=1` ensures glow outputs full ANSI colors even in fzf's non-TTY preview pipe
- Preview command runs via `sh -c '...' -- {}` for compatibility with fish, zsh, and bash
## [0.9.2] - 2026-03-04
### Changed
- Cache now uses stale-while-revalidate: cached projects are returned immediately and a background process (`--background-refresh`) rebuilds the cache when it is stale, eliminating blocking scans on every invocation
- `cache_ttl_hours` is now enforced: when the cache age exceeds the configured TTL, a background refresh is triggered automatically
## [0.9.1] - 2026-03-01 ## [0.9.1] - 2026-03-01
### Fixed ### Fixed

20
Cargo.lock generated
View File

@ -293,9 +293,9 @@ dependencies = [
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.4.2" version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
@ -570,18 +570,18 @@ dependencies = [
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]] [[package]]
name = "r-efi" name = "r-efi"
version = "6.0.0" version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]] [[package]]
name = "rand" name = "rand"
@ -809,7 +809,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom 0.4.2", "getrandom 0.4.1",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@ -857,7 +857,7 @@ dependencies = [
[[package]] [[package]]
name = "tmuxido" name = "tmuxido"
version = "0.10.0" version = "0.9.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@ -1133,9 +1133,9 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "0.7.15" version = "0.7.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]

View File

@ -1,6 +1,6 @@
[package] [package]
name = "tmuxido" name = "tmuxido"
version = "0.10.0" version = "0.9.1"
edition = "2024" edition = "2024"
[dev-dependencies] [dev-dependencies]

View File

@ -17,7 +17,7 @@ A Rust-based tool to quickly find and open projects in tmux using fzf. No extern
## Features ## Features
- Search for git repositories in configurable paths - Search for git repositories in configurable paths
- Interactive selection using fzf with live `README.md` preview (rendered via `glow` when available) - Interactive selection using fzf
- Native tmux session creation (no tmuxinator required!) - Native tmux session creation (no tmuxinator required!)
- Support for project-specific `.tmuxido.toml` configs - Support for project-specific `.tmuxido.toml` configs
- Smart session switching (reuses existing sessions) - Smart session switching (reuses existing sessions)

View File

@ -55,47 +55,9 @@ pub fn get_projects(config: &Config, force_refresh: bool) -> Result<Vec<PathBuf>
&ProjectCache::load, &ProjectCache::load,
&|cache| cache.save(), &|cache| cache.save(),
&scan_all_roots, &scan_all_roots,
&spawn_background_refresh,
) )
} }
/// Rebuilds the project cache incrementally. Intended to be called from a
/// background process spawned by `get_projects` via stale-while-revalidate.
pub fn refresh_cache(config: &Config) -> Result<()> {
match ProjectCache::load()? {
None => {
let (projects, fingerprints) = scan_all_roots(config)?;
ProjectCache::new(projects, fingerprints).save()?;
}
Some(mut cache) => {
if cache.dir_mtimes.is_empty() {
// Old cache format — full rescan
let (projects, fingerprints) = scan_all_roots(config)?;
ProjectCache::new(projects, fingerprints).save()?;
} else {
// Incremental rescan based on directory mtimes
let changed = cache.validate_and_update(&|root| scan_from_root(root, config))?;
if changed {
cache.save()?;
}
}
}
}
Ok(())
}
fn spawn_background_refresh() {
if let Ok(exe) = std::env::current_exe() {
std::process::Command::new(exe)
.arg("--background-refresh")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.ok();
}
}
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
fn get_projects_internal( fn get_projects_internal(
config: &Config, config: &Config,
@ -103,31 +65,45 @@ fn get_projects_internal(
cache_loader: &dyn Fn() -> Result<Option<ProjectCache>>, cache_loader: &dyn Fn() -> Result<Option<ProjectCache>>,
cache_saver: &dyn Fn(&ProjectCache) -> Result<()>, cache_saver: &dyn Fn(&ProjectCache) -> Result<()>,
scanner: &dyn Fn(&Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>, scanner: &dyn Fn(&Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>,
refresh_spawner: &dyn Fn(),
) -> Result<Vec<PathBuf>> { ) -> Result<Vec<PathBuf>> {
if !config.cache_enabled || force_refresh { if !config.cache_enabled || force_refresh {
let (projects, fingerprints) = scanner(config)?; let (projects, fingerprints) = scanner(config)?;
let cache = ProjectCache::new(projects.clone(), fingerprints); let cache = ProjectCache::new(projects.clone(), fingerprints);
cache_saver(&cache)?; cache_saver(&cache)?;
eprintln!("Cache updated with {} projects", projects.len());
return Ok(projects); return Ok(projects);
} }
if let Some(cache) = cache_loader()? { if let Some(mut cache) = cache_loader()? {
// Cache exists — return immediately (stale-while-revalidate). // Cache no formato antigo (sem dir_mtimes) → atualizar com rescan completo
// Spawn a background refresh if the cache is stale or in old format. if cache.dir_mtimes.is_empty() {
let is_stale = eprintln!("Upgrading cache, scanning for projects...");
cache.dir_mtimes.is_empty() || cache.age_in_seconds() > config.cache_ttl_hours * 3600; let (projects, fingerprints) = scanner(config)?;
if is_stale { let new_cache = ProjectCache::new(projects.clone(), fingerprints);
refresh_spawner(); cache_saver(&new_cache)?;
eprintln!("Cache updated with {} projects", projects.len());
return Ok(projects);
}
let changed = cache.validate_and_update(&|root| scan_from_root(root, config))?;
if changed {
cache_saver(&cache)?;
eprintln!(
"Cache updated incrementally ({} projects)",
cache.projects.len()
);
} else {
eprintln!("Using cached projects ({} projects)", cache.projects.len());
} }
return Ok(cache.projects); return Ok(cache.projects);
} }
// No cache yet — first run, blocking scan is unavoidable. // Sem cache ainda — scan completo inicial
eprintln!("No cache found, scanning for projects..."); eprintln!("No cache found, scanning for projects...");
let (projects, fingerprints) = scanner(config)?; let (projects, fingerprints) = scanner(config)?;
let cache = ProjectCache::new(projects.clone(), fingerprints); let cache = ProjectCache::new(projects.clone(), fingerprints);
cache_saver(&cache)?; cache_saver(&cache)?;
eprintln!("Cache updated with {} projects", projects.len());
Ok(projects) Ok(projects)
} }
@ -220,208 +196,242 @@ mod tests {
use super::*; use super::*;
use std::cell::RefCell; use std::cell::RefCell;
fn make_config(cache_enabled: bool, cache_ttl_hours: u64) -> Config { fn create_test_config(cache_enabled: bool) -> Config {
Config { Config {
paths: vec!["/tmp/test".to_string()], paths: vec!["/tmp/test".to_string()],
max_depth: 3, max_depth: 3,
cache_enabled, cache_enabled,
cache_ttl_hours, cache_ttl_hours: 24,
update_check_interval_hours: 24, update_check_interval_hours: 24,
default_session: session::SessionConfig { windows: vec![] }, default_session: session::SessionConfig { windows: vec![] },
} }
} }
fn fresh_cache(projects: Vec<PathBuf>) -> ProjectCache {
let fingerprints = HashMap::from([(PathBuf::from("/tracked"), 1u64)]);
ProjectCache::new(projects, fingerprints)
// last_updated = now_secs() — within any reasonable TTL
}
fn stale_cache(projects: Vec<PathBuf>) -> ProjectCache {
let fingerprints = HashMap::from([(PathBuf::from("/tracked"), 1u64)]);
let mut c = ProjectCache::new(projects, fingerprints);
c.last_updated = 0; // epoch — always older than TTL
c
}
fn call_internal(
config: &Config,
force_refresh: bool,
cache_loader: &dyn Fn() -> Result<Option<ProjectCache>>,
cache_saver: &dyn Fn(&ProjectCache) -> Result<()>,
scanner: &dyn Fn(&Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>,
refresh_spawner: &dyn Fn(),
) -> Result<Vec<PathBuf>> {
get_projects_internal(
config,
force_refresh,
cache_loader,
cache_saver,
scanner,
refresh_spawner,
)
}
#[test] #[test]
fn should_scan_when_cache_disabled() { fn should_scan_when_cache_disabled() {
let config = make_config(false, 24); let config = create_test_config(false);
let expected = vec![PathBuf::from("/p1")]; let projects = vec![PathBuf::from("/tmp/test/project1")];
let fingerprints = HashMap::new();
let expected_projects = projects.clone();
let scanner_called = RefCell::new(false); let scanner_called = RefCell::new(false);
let saver_called = RefCell::new(false); let saver_called = RefCell::new(false);
let spawner_called = RefCell::new(false);
let result = call_internal( let result = get_projects_internal(
&config, &config,
false, false,
&|| panic!("loader must not be called when cache disabled"), &|| panic!("should not load cache when disabled"),
&|_| { &|_| {
*saver_called.borrow_mut() = true; *saver_called.borrow_mut() = true;
Ok(()) Ok(())
}, },
&|_| { &|_| {
*scanner_called.borrow_mut() = true; *scanner_called.borrow_mut() = true;
Ok((expected.clone(), HashMap::new())) Ok((expected_projects.clone(), fingerprints.clone()))
}, },
&|| *spawner_called.borrow_mut() = true,
); );
assert!(result.is_ok()); assert!(result.is_ok());
assert!(scanner_called.into_inner()); assert!(scanner_called.into_inner());
assert!(saver_called.into_inner()); assert!(saver_called.into_inner());
assert!(!spawner_called.into_inner()); assert_eq!(result.unwrap(), projects);
assert_eq!(result.unwrap(), expected);
} }
#[test] #[test]
fn should_scan_when_force_refresh() { fn should_scan_when_force_refresh() {
let config = make_config(true, 24); let config = create_test_config(true);
let expected = vec![PathBuf::from("/p1")]; let projects = vec![PathBuf::from("/tmp/test/project1")];
let fingerprints = HashMap::new();
let expected_projects = projects.clone();
let scanner_called = RefCell::new(false); let scanner_called = RefCell::new(false);
let saver_called = RefCell::new(false); let saver_called = RefCell::new(false);
let spawner_called = RefCell::new(false);
let result = call_internal( let result = get_projects_internal(
&config, &config,
true, true,
&|| panic!("loader must not be called on force refresh"), &|| panic!("should not load cache when force refresh"),
&|_| { &|_| {
*saver_called.borrow_mut() = true; *saver_called.borrow_mut() = true;
Ok(()) Ok(())
}, },
&|_| { &|_| {
*scanner_called.borrow_mut() = true; *scanner_called.borrow_mut() = true;
Ok((expected.clone(), HashMap::new())) Ok((expected_projects.clone(), fingerprints.clone()))
}, },
&|| *spawner_called.borrow_mut() = true,
); );
assert!(result.is_ok()); assert!(result.is_ok());
assert!(scanner_called.into_inner()); assert!(scanner_called.into_inner());
assert!(saver_called.into_inner()); assert!(saver_called.into_inner());
assert!(!spawner_called.into_inner()); assert_eq!(result.unwrap(), projects);
assert_eq!(result.unwrap(), expected);
} }
#[test] #[test]
fn should_do_blocking_scan_when_no_cache_exists() { fn should_do_initial_scan_when_no_cache_exists() {
let config = make_config(true, 24); let config = create_test_config(true);
let expected = vec![PathBuf::from("/p1")]; let projects = vec![PathBuf::from("/tmp/test/project1")];
let fingerprints = HashMap::new();
let expected_projects = projects.clone();
let loader_called = RefCell::new(false);
let scanner_called = RefCell::new(false); let scanner_called = RefCell::new(false);
let saver_called = RefCell::new(false); let saver_called = RefCell::new(false);
let spawner_called = RefCell::new(false);
let result = call_internal( let result = get_projects_internal(
&config, &config,
false, false,
&|| Ok(None), &|| {
*loader_called.borrow_mut() = true;
Ok(None)
},
&|_| { &|_| {
*saver_called.borrow_mut() = true; *saver_called.borrow_mut() = true;
Ok(()) Ok(())
}, },
&|_| { &|_| {
*scanner_called.borrow_mut() = true; *scanner_called.borrow_mut() = true;
Ok((expected.clone(), HashMap::new())) Ok((expected_projects.clone(), fingerprints.clone()))
}, },
&|| *spawner_called.borrow_mut() = true,
); );
assert!(result.is_ok()); assert!(result.is_ok());
assert!(loader_called.into_inner());
assert!(scanner_called.into_inner()); assert!(scanner_called.into_inner());
assert!(saver_called.into_inner()); assert!(saver_called.into_inner());
assert!(!spawner_called.into_inner()); assert_eq!(result.unwrap(), projects);
assert_eq!(result.unwrap(), expected);
} }
#[test] #[test]
fn should_return_cached_projects_immediately_when_cache_is_fresh() { fn should_upgrade_old_cache_format() {
let config = make_config(true, 24); let config = create_test_config(true);
let cached = vec![PathBuf::from("/cached/project")]; let old_projects = vec![PathBuf::from("/old/project")];
let cache = RefCell::new(Some(fresh_cache(cached.clone()))); let new_projects = vec![
let spawner_called = RefCell::new(false); PathBuf::from("/new/project1"),
PathBuf::from("/new/project2"),
];
let new_fingerprints = HashMap::from([(PathBuf::from("/new"), 12345u64)]);
let result = call_internal( // Use RefCell<Option<>> to allow moving into closure multiple times
let old_cache = RefCell::new(Some(ProjectCache::new(old_projects, HashMap::new())));
let loader_called = RefCell::new(false);
let scanner_called = RefCell::new(false);
let saver_count = RefCell::new(0);
let result = get_projects_internal(
&config, &config,
false, false,
&|| Ok(cache.borrow_mut().take()), &|| {
&|_| panic!("saver must not be called in foreground"), *loader_called.borrow_mut() = true;
&|_| panic!("scanner must not be called when cache is fresh"), // Take the cache out of the RefCell
&|| *spawner_called.borrow_mut() = true, Ok(old_cache.borrow_mut().take())
},
&|_| {
*saver_count.borrow_mut() += 1;
Ok(())
},
&|_| {
*scanner_called.borrow_mut() = true;
Ok((new_projects.clone(), new_fingerprints.clone()))
},
); );
assert!(result.is_ok()); assert!(result.is_ok());
assert_eq!(result.unwrap(), cached); assert!(loader_called.into_inner());
assert!( assert!(scanner_called.into_inner());
!spawner_called.into_inner(), assert_eq!(*saver_count.borrow(), 1);
"fresh cache should not trigger background refresh" assert_eq!(result.unwrap(), new_projects);
);
} }
#[test] #[test]
fn should_return_stale_cache_immediately_and_spawn_background_refresh() { fn should_use_cached_projects_when_nothing_changed() {
let config = make_config(true, 24); let config = create_test_config(true);
let cached = vec![PathBuf::from("/cached/project")]; let cached_projects = vec![
let cache = RefCell::new(Some(stale_cache(cached.clone()))); PathBuf::from("/nonexistent/project1"),
let spawner_called = RefCell::new(false); PathBuf::from("/nonexistent/project2"),
];
// Use a path that doesn't exist - validate_and_update will skip rescan
// because it can't check mtime of non-existent directory
let cached_fingerprints =
HashMap::from([(PathBuf::from("/definitely_nonexistent_path_xyz"), 12345u64)]);
let result = call_internal( // Use RefCell<Option<>> to allow moving into closure multiple times
let cache = RefCell::new(Some(ProjectCache::new(
cached_projects.clone(),
cached_fingerprints,
)));
let loader_called = RefCell::new(false);
let scanner_called = RefCell::new(false);
let saver_count = RefCell::new(0);
let result = get_projects_internal(
&config, &config,
false, false,
&|| Ok(cache.borrow_mut().take()), &|| {
&|_| panic!("saver must not be called in foreground"), *loader_called.borrow_mut() = true;
&|_| panic!("scanner must not be called in foreground"), // Take the cache out of the RefCell
&|| *spawner_called.borrow_mut() = true, Ok(cache.borrow_mut().take())
},
&|_| {
*saver_count.borrow_mut() += 1;
Ok(())
},
&|_| {
*scanner_called.borrow_mut() = true;
panic!("should not do full scan when cache is valid")
},
); );
assert!(result.is_ok()); assert!(result.is_ok());
assert_eq!(result.unwrap(), cached); assert!(loader_called.into_inner());
assert!( // Note: When the directory in dir_mtimes doesn't exist, validate_and_update
spawner_called.into_inner(), // treats it as "changed" and removes projects under that path.
"stale cache must trigger background refresh" // This test verifies the flow completes - the specific behavior of
); // validate_and_update is tested separately in cache.rs
let result_projects = result.unwrap();
// Projects were removed because the tracked directory doesn't exist
assert!(result_projects.is_empty());
} }
#[test] #[test]
fn should_spawn_background_refresh_when_cache_has_no_fingerprints() { fn should_update_incrementally_when_cache_changed() {
let config = make_config(true, 24); let config = create_test_config(true);
let cached = vec![PathBuf::from("/old/project")]; let initial_projects = vec![PathBuf::from("/nonexistent/project1")];
// Old cache format: no dir_mtimes // Use a path that doesn't exist - validate_and_update will treat missing
let old_cache = RefCell::new(Some(ProjectCache::new(cached.clone(), HashMap::new()))); // directory as a change (unwrap_or(true) in the mtime check)
let spawner_called = RefCell::new(false); let mut dir_mtimes = HashMap::new();
dir_mtimes.insert(PathBuf::from("/definitely_nonexistent_path_abc"), 0u64);
let result = call_internal( // Use RefCell<Option<>> to allow moving into closure multiple times
let cache = RefCell::new(Some(ProjectCache::new(initial_projects, dir_mtimes)));
let loader_called = RefCell::new(false);
let saver_called = RefCell::new(false);
let result = get_projects_internal(
&config, &config,
false, false,
&|| Ok(old_cache.borrow_mut().take()), &|| {
&|_| panic!("saver must not be called in foreground"), *loader_called.borrow_mut() = true;
&|_| panic!("scanner must not be called in foreground"), // Take the cache out of the RefCell
&|| *spawner_called.borrow_mut() = true, Ok(cache.borrow_mut().take())
},
&|_| {
*saver_called.borrow_mut() = true;
Ok(())
},
&|_| panic!("full scan should not happen with incremental update"),
); );
// validate_and_update is called internally. Since the directory doesn't exist,
// it treats it as "changed" and will try to rescan using scan_from_root.
// We verify the flow completes without panicking.
assert!(result.is_ok()); assert!(result.is_ok());
assert_eq!(result.unwrap(), cached); assert!(loader_called.into_inner());
assert!( // Note: The saver may or may not be called depending on whether
spawner_called.into_inner(), // validate_and_update detects changes (missing dir = change)
"old cache format must trigger background refresh"
);
} }
} }

View File

@ -8,8 +8,8 @@ use tmuxido::deps::ensure_dependencies;
use tmuxido::self_update; use tmuxido::self_update;
use tmuxido::update_check; use tmuxido::update_check;
use tmuxido::{ use tmuxido::{
get_projects, launch_tmux_session, refresh_cache, setup_desktop_integration_wizard, get_projects, launch_tmux_session, setup_desktop_integration_wizard, setup_shortcut_wizard,
setup_shortcut_wizard, show_cache_status, show_cache_status,
}; };
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
@ -41,10 +41,6 @@ struct Args {
/// Install the .desktop entry and icon for app launcher integration /// Install the .desktop entry and icon for app launcher integration
#[arg(long)] #[arg(long)]
setup_desktop_shortcut: bool, setup_desktop_shortcut: bool,
/// Internal: rebuild cache in background (used by stale-while-revalidate)
#[arg(long, hide = true)]
background_refresh: bool,
} }
fn main() -> Result<()> { fn main() -> Result<()> {
@ -55,13 +51,6 @@ fn main() -> Result<()> {
return self_update::self_update(); return self_update::self_update();
} }
// Handle background cache refresh (spawned internally by stale-while-revalidate).
// Runs early to avoid unnecessary dependency checks and config prompts.
if args.background_refresh {
let config = Config::load()?;
return refresh_cache(&config);
}
// Handle standalone shortcut setup // Handle standalone shortcut setup
if args.setup_shortcut { if args.setup_shortcut {
return setup_shortcut_wizard(); return setup_shortcut_wizard();
@ -116,34 +105,8 @@ fn main() -> Result<()> {
Ok(()) Ok(())
} }
fn readme_preview_command() -> String {
let glow_available = Command::new("sh")
.args(["-c", "command -v glow"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
// CLICOLOR_FORCE=1 tells termenv (used by glow/glamour) to enable ANSI
// colors even when stdout is not a TTY (fzf preview runs in a pipe).
// Without it, glow falls back to bold-only "notty" style with no colors.
// Use `sh -c '...' -- {}` so the command runs in POSIX sh regardless of
// the user's $SHELL (fish, zsh, bash, etc.).
let viewer_cmd = if glow_available {
"CLICOLOR_FORCE=1 glow -s dark"
} else {
"cat"
};
format!(
r#"sh -c 'readme="$1/README.md"; [ -f "$readme" ] && {viewer_cmd} "$readme" || echo "No README.md"' -- {{}}"#
)
}
fn select_project_with_fzf(projects: &[PathBuf]) -> Result<PathBuf> { fn select_project_with_fzf(projects: &[PathBuf]) -> Result<PathBuf> {
let preview_cmd = readme_preview_command();
let mut child = Command::new("fzf") let mut child = Command::new("fzf")
.arg("--preview")
.arg(&preview_cmd)
.arg("--preview-window")
.arg("right:40%")
.stdin(Stdio::piped()) .stdin(Stdio::piped())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.spawn() .spawn()