Compare commits

...

2 Commits
0.9.1 ... main

Author SHA1 Message Date
d3380c668a feat: fzf README preview with glow support
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
- Show README.md in the right 40% of the fzf picker on hover
- Render with glow (CLICOLOR_FORCE=1 for colors in non-TTY pipe) when available, fall back to cat
- Preview command uses sh -c for fish/zsh/bash compatibility
- Bump version to 0.10.0

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-03-05 20:24:13 -03:00
8aa341080d perf: stale-while-revalidate cache — always instant startup
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
Cache is now returned immediately on every run. When stale (age >
cache_ttl_hours), a detached background process rebuilds it
incrementally via --background-refresh, so blocking scans never
happen in the foreground after the first run.

cache_ttl_hours is now actually enforced (previously ignored).
2026-03-04 23:39:30 -03:00
6 changed files with 219 additions and 178 deletions

View File

@ -4,6 +4,20 @@ 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/).
## [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
### Fixed

20
Cargo.lock generated
View File

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

View File

@ -1,6 +1,6 @@
[package]
name = "tmuxido"
version = "0.9.1"
version = "0.10.0"
edition = "2024"
[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
- Search for git repositories in configurable paths
- Interactive selection using fzf
- Interactive selection using fzf with live `README.md` preview (rendered via `glow` when available)
- Native tmux session creation (no tmuxinator required!)
- Support for project-specific `.tmuxido.toml` configs
- Smart session switching (reuses existing sessions)

View File

@ -55,9 +55,47 @@ pub fn get_projects(config: &Config, force_refresh: bool) -> Result<Vec<PathBuf>
&ProjectCache::load,
&|cache| cache.save(),
&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)]
fn get_projects_internal(
config: &Config,
@ -65,45 +103,31 @@ fn get_projects_internal(
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>> {
if !config.cache_enabled || force_refresh {
let (projects, fingerprints) = scanner(config)?;
let cache = ProjectCache::new(projects.clone(), fingerprints);
cache_saver(&cache)?;
eprintln!("Cache updated with {} projects", projects.len());
return Ok(projects);
}
if let Some(mut cache) = cache_loader()? {
// Cache no formato antigo (sem dir_mtimes) → atualizar com rescan completo
if cache.dir_mtimes.is_empty() {
eprintln!("Upgrading cache, scanning for projects...");
let (projects, fingerprints) = scanner(config)?;
let new_cache = ProjectCache::new(projects.clone(), fingerprints);
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());
if let Some(cache) = cache_loader()? {
// Cache exists — return immediately (stale-while-revalidate).
// Spawn a background refresh if the cache is stale or in old format.
let is_stale =
cache.dir_mtimes.is_empty() || cache.age_in_seconds() > config.cache_ttl_hours * 3600;
if is_stale {
refresh_spawner();
}
return Ok(cache.projects);
}
// Sem cache ainda — scan completo inicial
// No cache yet — first run, blocking scan is unavoidable.
eprintln!("No cache found, scanning for projects...");
let (projects, fingerprints) = scanner(config)?;
let cache = ProjectCache::new(projects.clone(), fingerprints);
cache_saver(&cache)?;
eprintln!("Cache updated with {} projects", projects.len());
Ok(projects)
}
@ -196,242 +220,208 @@ mod tests {
use super::*;
use std::cell::RefCell;
fn create_test_config(cache_enabled: bool) -> Config {
fn make_config(cache_enabled: bool, cache_ttl_hours: u64) -> Config {
Config {
paths: vec!["/tmp/test".to_string()],
max_depth: 3,
cache_enabled,
cache_ttl_hours: 24,
cache_ttl_hours,
update_check_interval_hours: 24,
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]
fn should_scan_when_cache_disabled() {
let config = create_test_config(false);
let projects = vec![PathBuf::from("/tmp/test/project1")];
let fingerprints = HashMap::new();
let expected_projects = projects.clone();
let config = make_config(false, 24);
let expected = vec![PathBuf::from("/p1")];
let scanner_called = RefCell::new(false);
let saver_called = RefCell::new(false);
let spawner_called = RefCell::new(false);
let result = get_projects_internal(
let result = call_internal(
&config,
false,
&|| panic!("should not load cache when disabled"),
&|| panic!("loader must not be called when cache disabled"),
&|_| {
*saver_called.borrow_mut() = true;
Ok(())
},
&|_| {
*scanner_called.borrow_mut() = true;
Ok((expected_projects.clone(), fingerprints.clone()))
Ok((expected.clone(), HashMap::new()))
},
&|| *spawner_called.borrow_mut() = true,
);
assert!(result.is_ok());
assert!(scanner_called.into_inner());
assert!(saver_called.into_inner());
assert_eq!(result.unwrap(), projects);
assert!(!spawner_called.into_inner());
assert_eq!(result.unwrap(), expected);
}
#[test]
fn should_scan_when_force_refresh() {
let config = create_test_config(true);
let projects = vec![PathBuf::from("/tmp/test/project1")];
let fingerprints = HashMap::new();
let expected_projects = projects.clone();
let config = make_config(true, 24);
let expected = vec![PathBuf::from("/p1")];
let scanner_called = RefCell::new(false);
let saver_called = RefCell::new(false);
let spawner_called = RefCell::new(false);
let result = get_projects_internal(
let result = call_internal(
&config,
true,
&|| panic!("should not load cache when force refresh"),
&|| panic!("loader must not be called on force refresh"),
&|_| {
*saver_called.borrow_mut() = true;
Ok(())
},
&|_| {
*scanner_called.borrow_mut() = true;
Ok((expected_projects.clone(), fingerprints.clone()))
Ok((expected.clone(), HashMap::new()))
},
&|| *spawner_called.borrow_mut() = true,
);
assert!(result.is_ok());
assert!(scanner_called.into_inner());
assert!(saver_called.into_inner());
assert_eq!(result.unwrap(), projects);
assert!(!spawner_called.into_inner());
assert_eq!(result.unwrap(), expected);
}
#[test]
fn should_do_initial_scan_when_no_cache_exists() {
let config = create_test_config(true);
let projects = vec![PathBuf::from("/tmp/test/project1")];
let fingerprints = HashMap::new();
let expected_projects = projects.clone();
let loader_called = RefCell::new(false);
fn should_do_blocking_scan_when_no_cache_exists() {
let config = make_config(true, 24);
let expected = vec![PathBuf::from("/p1")];
let scanner_called = RefCell::new(false);
let saver_called = RefCell::new(false);
let spawner_called = RefCell::new(false);
let result = get_projects_internal(
let result = call_internal(
&config,
false,
&|| {
*loader_called.borrow_mut() = true;
Ok(None)
},
&|| Ok(None),
&|_| {
*saver_called.borrow_mut() = true;
Ok(())
},
&|_| {
*scanner_called.borrow_mut() = true;
Ok((expected_projects.clone(), fingerprints.clone()))
Ok((expected.clone(), HashMap::new()))
},
&|| *spawner_called.borrow_mut() = true,
);
assert!(result.is_ok());
assert!(loader_called.into_inner());
assert!(scanner_called.into_inner());
assert!(saver_called.into_inner());
assert_eq!(result.unwrap(), projects);
assert!(!spawner_called.into_inner());
assert_eq!(result.unwrap(), expected);
}
#[test]
fn should_upgrade_old_cache_format() {
let config = create_test_config(true);
let old_projects = vec![PathBuf::from("/old/project")];
let new_projects = vec![
PathBuf::from("/new/project1"),
PathBuf::from("/new/project2"),
];
let new_fingerprints = HashMap::from([(PathBuf::from("/new"), 12345u64)]);
fn should_return_cached_projects_immediately_when_cache_is_fresh() {
let config = make_config(true, 24);
let cached = vec![PathBuf::from("/cached/project")];
let cache = RefCell::new(Some(fresh_cache(cached.clone())));
let spawner_called = RefCell::new(false);
// 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(
let result = call_internal(
&config,
false,
&|| {
*loader_called.borrow_mut() = true;
// Take the cache out of the RefCell
Ok(old_cache.borrow_mut().take())
},
&|_| {
*saver_count.borrow_mut() += 1;
Ok(())
},
&|_| {
*scanner_called.borrow_mut() = true;
Ok((new_projects.clone(), new_fingerprints.clone()))
},
&|| Ok(cache.borrow_mut().take()),
&|_| panic!("saver must not be called in foreground"),
&|_| panic!("scanner must not be called when cache is fresh"),
&|| *spawner_called.borrow_mut() = true,
);
assert!(result.is_ok());
assert!(loader_called.into_inner());
assert!(scanner_called.into_inner());
assert_eq!(*saver_count.borrow(), 1);
assert_eq!(result.unwrap(), new_projects);
assert_eq!(result.unwrap(), cached);
assert!(
!spawner_called.into_inner(),
"fresh cache should not trigger background refresh"
);
}
#[test]
fn should_use_cached_projects_when_nothing_changed() {
let config = create_test_config(true);
let cached_projects = vec![
PathBuf::from("/nonexistent/project1"),
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)]);
fn should_return_stale_cache_immediately_and_spawn_background_refresh() {
let config = make_config(true, 24);
let cached = vec![PathBuf::from("/cached/project")];
let cache = RefCell::new(Some(stale_cache(cached.clone())));
let spawner_called = RefCell::new(false);
// 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(
let result = call_internal(
&config,
false,
&|| {
*loader_called.borrow_mut() = true;
// Take the cache out of the RefCell
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")
},
&|| Ok(cache.borrow_mut().take()),
&|_| panic!("saver must not be called in foreground"),
&|_| panic!("scanner must not be called in foreground"),
&|| *spawner_called.borrow_mut() = true,
);
assert!(result.is_ok());
assert!(loader_called.into_inner());
// Note: When the directory in dir_mtimes doesn't exist, validate_and_update
// treats it as "changed" and removes projects under that path.
// 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());
assert_eq!(result.unwrap(), cached);
assert!(
spawner_called.into_inner(),
"stale cache must trigger background refresh"
);
}
#[test]
fn should_update_incrementally_when_cache_changed() {
let config = create_test_config(true);
let initial_projects = vec![PathBuf::from("/nonexistent/project1")];
// Use a path that doesn't exist - validate_and_update will treat missing
// directory as a change (unwrap_or(true) in the mtime check)
let mut dir_mtimes = HashMap::new();
dir_mtimes.insert(PathBuf::from("/definitely_nonexistent_path_abc"), 0u64);
fn should_spawn_background_refresh_when_cache_has_no_fingerprints() {
let config = make_config(true, 24);
let cached = vec![PathBuf::from("/old/project")];
// Old cache format: no dir_mtimes
let old_cache = RefCell::new(Some(ProjectCache::new(cached.clone(), HashMap::new())));
let spawner_called = RefCell::new(false);
// 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(
let result = call_internal(
&config,
false,
&|| {
*loader_called.borrow_mut() = true;
// Take the cache out of the RefCell
Ok(cache.borrow_mut().take())
},
&|_| {
*saver_called.borrow_mut() = true;
Ok(())
},
&|_| panic!("full scan should not happen with incremental update"),
&|| Ok(old_cache.borrow_mut().take()),
&|_| panic!("saver must not be called in foreground"),
&|_| panic!("scanner must not be called in foreground"),
&|| *spawner_called.borrow_mut() = true,
);
// 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!(loader_called.into_inner());
// Note: The saver may or may not be called depending on whether
// validate_and_update detects changes (missing dir = change)
assert_eq!(result.unwrap(), cached);
assert!(
spawner_called.into_inner(),
"old cache format must trigger background refresh"
);
}
}

View File

@ -8,8 +8,8 @@ use tmuxido::deps::ensure_dependencies;
use tmuxido::self_update;
use tmuxido::update_check;
use tmuxido::{
get_projects, launch_tmux_session, setup_desktop_integration_wizard, setup_shortcut_wizard,
show_cache_status,
get_projects, launch_tmux_session, refresh_cache, setup_desktop_integration_wizard,
setup_shortcut_wizard, show_cache_status,
};
#[derive(Parser, Debug)]
@ -41,6 +41,10 @@ struct Args {
/// Install the .desktop entry and icon for app launcher integration
#[arg(long)]
setup_desktop_shortcut: bool,
/// Internal: rebuild cache in background (used by stale-while-revalidate)
#[arg(long, hide = true)]
background_refresh: bool,
}
fn main() -> Result<()> {
@ -51,6 +55,13 @@ fn main() -> Result<()> {
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
if args.setup_shortcut {
return setup_shortcut_wizard();
@ -105,8 +116,34 @@ fn main() -> Result<()> {
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> {
let preview_cmd = readme_preview_command();
let mut child = Command::new("fzf")
.arg("--preview")
.arg(&preview_cmd)
.arg("--preview-window")
.arg("right:40%")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()