forked from cinco/tmuxido
Initial release of tmuxido
Rust-based tmux project launcher with fzf selection, incremental mtime-based cache, per-project .tmuxido.toml session config, and Drone CI pipeline for automated binary releases.
This commit is contained in:
+251
@@ -0,0 +1,251 @@
|
||||
mod cache;
|
||||
mod config;
|
||||
mod session;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use cache::ProjectCache;
|
||||
use clap::Parser;
|
||||
use config::Config;
|
||||
use session::{SessionConfig, TmuxSession};
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::UNIX_EPOCH;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "tmuxido",
|
||||
about = "Quickly find and open projects in tmux",
|
||||
version
|
||||
)]
|
||||
struct Args {
|
||||
/// Project path to open directly (skips selection)
|
||||
project_path: Option<PathBuf>,
|
||||
|
||||
/// Force refresh the project cache
|
||||
#[arg(short, long)]
|
||||
refresh: bool,
|
||||
|
||||
/// Show cache status and exit
|
||||
#[arg(long)]
|
||||
cache_status: bool,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
// Ensure config exists
|
||||
Config::ensure_config_exists()?;
|
||||
|
||||
// Load config
|
||||
let config = Config::load()?;
|
||||
|
||||
// Handle cache status command
|
||||
if args.cache_status {
|
||||
show_cache_status(&config)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let selected = if let Some(path) = args.project_path {
|
||||
path
|
||||
} else {
|
||||
// Get projects (from cache or scan)
|
||||
let projects = get_projects(&config, args.refresh)?;
|
||||
|
||||
if projects.is_empty() {
|
||||
eprintln!("No projects found in configured paths");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Use fzf to select a project
|
||||
select_project_with_fzf(&projects)?
|
||||
};
|
||||
|
||||
if !selected.exists() {
|
||||
eprintln!("Selected path does not exist: {}", selected.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Launch tmux session
|
||||
launch_tmux_session(&selected, &config)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn show_cache_status(config: &Config) -> Result<()> {
|
||||
if !config.cache_enabled {
|
||||
println!("Cache is disabled in configuration");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(cache) = ProjectCache::load()? {
|
||||
let age_seconds = cache.age_in_seconds();
|
||||
let age_hours = age_seconds / 3600;
|
||||
let age_minutes = (age_seconds % 3600) / 60;
|
||||
|
||||
println!("Cache status:");
|
||||
println!(" Location: {}", ProjectCache::cache_path()?.display());
|
||||
println!(" Projects cached: {}", cache.projects.len());
|
||||
println!(" Directories tracked: {}", cache.dir_mtimes.len());
|
||||
println!(" Last updated: {}h {}m ago", age_hours, age_minutes);
|
||||
} else {
|
||||
println!("No cache found");
|
||||
println!(" Run without --cache-status to create it");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_projects(config: &Config, force_refresh: bool) -> Result<Vec<PathBuf>> {
|
||||
if !config.cache_enabled || force_refresh {
|
||||
let (projects, fingerprints) = scan_all_roots(config)?;
|
||||
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
||||
cache.save()?;
|
||||
eprintln!("Cache updated with {} projects", projects.len());
|
||||
return Ok(projects);
|
||||
}
|
||||
|
||||
if let Some(mut cache) = ProjectCache::load()? {
|
||||
// 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) = scan_all_roots(config)?;
|
||||
let new_cache = ProjectCache::new(projects.clone(), fingerprints);
|
||||
new_cache.save()?;
|
||||
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.save()?;
|
||||
eprintln!(
|
||||
"Cache updated incrementally ({} projects)",
|
||||
cache.projects.len()
|
||||
);
|
||||
} else {
|
||||
eprintln!("Using cached projects ({} projects)", cache.projects.len());
|
||||
}
|
||||
return Ok(cache.projects);
|
||||
}
|
||||
|
||||
// Sem cache ainda — scan completo inicial
|
||||
eprintln!("No cache found, scanning for projects...");
|
||||
let (projects, fingerprints) = scan_all_roots(config)?;
|
||||
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
||||
cache.save()?;
|
||||
eprintln!("Cache updated with {} projects", projects.len());
|
||||
Ok(projects)
|
||||
}
|
||||
|
||||
fn scan_all_roots(config: &Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)> {
|
||||
let mut all_projects = Vec::new();
|
||||
let mut all_fingerprints = HashMap::new();
|
||||
|
||||
for path_str in &config.paths {
|
||||
let path = PathBuf::from(shellexpand::tilde(path_str).to_string());
|
||||
|
||||
if !path.exists() {
|
||||
eprintln!("Warning: Path does not exist: {}", path.display());
|
||||
continue;
|
||||
}
|
||||
|
||||
eprintln!("Scanning: {}", path.display());
|
||||
|
||||
let (projects, fingerprints) = scan_from_root(&path, config)?;
|
||||
all_projects.extend(projects);
|
||||
all_fingerprints.extend(fingerprints);
|
||||
}
|
||||
|
||||
all_projects.sort();
|
||||
all_projects.dedup();
|
||||
|
||||
Ok((all_projects, all_fingerprints))
|
||||
}
|
||||
|
||||
fn scan_from_root(root: &Path, config: &Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)> {
|
||||
let mut projects = Vec::new();
|
||||
let mut fingerprints = HashMap::new();
|
||||
|
||||
for entry in WalkDir::new(root)
|
||||
.max_depth(config.max_depth)
|
||||
.follow_links(false)
|
||||
.into_iter()
|
||||
.filter_entry(|e| {
|
||||
e.file_name()
|
||||
.to_str()
|
||||
.map(|s| !s.starts_with('.') || s == ".git")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
{
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if entry.file_type().is_dir() {
|
||||
if entry.file_name() == ".git" {
|
||||
// Projeto encontrado
|
||||
if let Some(parent) = entry.path().parent() {
|
||||
projects.push(parent.to_path_buf());
|
||||
}
|
||||
} else {
|
||||
// Registrar mtime para detecção de mudanças futuras
|
||||
if let Ok(metadata) = entry.metadata() {
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
let mtime = modified
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
fingerprints.insert(entry.path().to_path_buf(), mtime);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((projects, fingerprints))
|
||||
}
|
||||
|
||||
fn select_project_with_fzf(projects: &[PathBuf]) -> Result<PathBuf> {
|
||||
let mut child = Command::new("fzf")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.context("Failed to spawn fzf. Make sure fzf is installed.")?;
|
||||
|
||||
{
|
||||
let stdin = child.stdin.as_mut().context("Failed to open stdin")?;
|
||||
for project in projects {
|
||||
writeln!(stdin, "{}", project.display())?;
|
||||
}
|
||||
}
|
||||
|
||||
let output = child.wait_with_output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
let selected = String::from_utf8(output.stdout)?.trim().to_string();
|
||||
|
||||
if selected.is_empty() {
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
Ok(PathBuf::from(selected))
|
||||
}
|
||||
|
||||
fn launch_tmux_session(selected: &Path, config: &Config) -> Result<()> {
|
||||
// Try to load project-specific config, fallback to global default
|
||||
let session_config = SessionConfig::load_from_project(selected)?
|
||||
.unwrap_or_else(|| config.default_session.clone());
|
||||
|
||||
// Create tmux session
|
||||
let tmux_session = TmuxSession::new(selected);
|
||||
tmux_session.create(&session_config)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user