Initial release of tmuxido
continuous-integration/drone/tag Build is failing

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:
2026-02-28 19:06:43 -03:00
commit 71da4149b8
13 changed files with 2195 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Serialize, Deserialize)]
pub struct ProjectCache {
pub projects: Vec<PathBuf>,
pub last_updated: u64,
/// mtime de cada diretório visitado durante o scan.
/// Usado para detectar mudanças incrementais sem precisar varrer tudo.
#[serde(default)]
pub dir_mtimes: HashMap<PathBuf, u64>,
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
fn mtime_secs(time: SystemTime) -> u64 {
time.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
}
/// Retorna o subconjunto mínimo de diretórios: aqueles que não têm nenhum
/// ancestral também na lista. Evita rescanear a mesma subárvore duas vezes.
fn minimal_roots(dirs: &[PathBuf]) -> Vec<PathBuf> {
dirs.iter()
.filter(|dir| !dirs.iter().any(|other| other != *dir && dir.starts_with(other)))
.cloned()
.collect()
}
impl ProjectCache {
pub fn new(projects: Vec<PathBuf>, dir_mtimes: HashMap<PathBuf, u64>) -> Self {
Self {
projects,
last_updated: now_secs(),
dir_mtimes,
}
}
pub fn cache_path() -> Result<PathBuf> {
let cache_dir = dirs::cache_dir()
.context("Could not determine cache directory")?
.join("tmuxido");
fs::create_dir_all(&cache_dir)
.with_context(|| format!("Failed to create cache directory: {}", cache_dir.display()))?;
Ok(cache_dir.join("projects.json"))
}
pub fn load() -> Result<Option<Self>> {
let cache_path = Self::cache_path()?;
if !cache_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&cache_path)
.with_context(|| format!("Failed to read cache file: {}", cache_path.display()))?;
let cache: ProjectCache = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse cache file: {}", cache_path.display()))?;
Ok(Some(cache))
}
pub fn save(&self) -> Result<()> {
let cache_path = Self::cache_path()?;
let content = serde_json::to_string_pretty(self)
.context("Failed to serialize cache")?;
fs::write(&cache_path, content)
.with_context(|| format!("Failed to write cache file: {}", cache_path.display()))?;
Ok(())
}
/// Valida e atualiza o cache de forma incremental.
///
/// 1. Remove projetos cujo `.git` não existe mais.
/// 2. Detecta diretórios com mtime alterado.
/// 3. Resscaneia apenas as subárvores mínimas que mudaram.
///
/// Retorna `true` se o cache foi modificado.
/// Retorna `false` com `dir_mtimes` vazio (cache antigo) — chamador deve fazer rescan completo.
pub fn validate_and_update(
&mut self,
scan_fn: &dyn Fn(&Path) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>,
) -> Result<bool> {
let mut changed = false;
// Passo 1: remover projetos cujo .git não existe mais
let before = self.projects.len();
self.projects.retain(|p| p.join(".git").exists());
if self.projects.len() != before {
changed = true;
}
// Sem fingerprints = cache no formato antigo; sinaliza ao chamador
if self.dir_mtimes.is_empty() {
return Ok(changed);
}
// Passo 2: encontrar diretórios com mtime diferente do armazenado
let changed_dirs: Vec<PathBuf> = self
.dir_mtimes
.iter()
.filter(|(dir, stored_mtime)| {
fs::metadata(dir)
.and_then(|m| m.modified())
.map(|t| mtime_secs(t) != **stored_mtime)
.unwrap_or(true) // diretório sumiu = tratar como mudança
})
.map(|(dir, _)| dir.clone())
.collect();
if changed_dirs.is_empty() {
return Ok(changed);
}
// Passo 3: resscanear apenas as raízes mínimas das subárvores alteradas
for root in minimal_roots(&changed_dirs) {
eprintln!("Rescanning: {}", root.display());
// Remover entradas antigas desta subárvore
self.projects.retain(|p| !p.starts_with(&root));
self.dir_mtimes.retain(|d, _| !d.starts_with(&root));
// Resscanear e mesclar
let (new_projects, new_fingerprints) = scan_fn(&root)?;
self.projects.extend(new_projects);
self.dir_mtimes.extend(new_fingerprints);
changed = true;
}
if changed {
self.projects.sort();
self.projects.dedup();
self.last_updated = now_secs();
}
Ok(changed)
}
pub fn age_in_seconds(&self) -> u64 {
now_secs().saturating_sub(self.last_updated)
}
}
+116
View File
@@ -0,0 +1,116 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use crate::session::SessionConfig;
#[derive(Debug, Deserialize, Serialize)]
pub struct Config {
pub paths: Vec<String>,
#[serde(default = "default_max_depth")]
pub max_depth: usize,
#[serde(default = "default_cache_enabled")]
pub cache_enabled: bool,
#[serde(default = "default_cache_ttl_hours")]
pub cache_ttl_hours: u64,
#[serde(default = "default_session_config")]
pub default_session: SessionConfig,
}
fn default_max_depth() -> usize {
5
}
fn default_cache_enabled() -> bool {
true
}
fn default_cache_ttl_hours() -> u64 {
24
}
fn default_session_config() -> SessionConfig {
use crate::session::Window;
SessionConfig {
windows: vec![
Window {
name: "editor".to_string(),
panes: vec![],
layout: None,
},
Window {
name: "terminal".to_string(),
panes: vec![],
layout: None,
},
],
}
}
impl Config {
pub fn load() -> Result<Self> {
let config_path = Self::config_path()?;
if !config_path.exists() {
return Ok(Self::default_config());
}
let content = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
let config: Config = toml::from_str(&content)
.with_context(|| format!("Failed to parse config file: {}", config_path.display()))?;
Ok(config)
}
pub fn config_path() -> Result<PathBuf> {
let config_dir = dirs::config_dir()
.context("Could not determine config directory")?
.join("tmuxido");
Ok(config_dir.join("tmuxido.toml"))
}
pub fn ensure_config_exists() -> Result<PathBuf> {
let config_path = Self::config_path()?;
if !config_path.exists() {
let config_dir = config_path.parent()
.context("Could not get parent directory")?;
fs::create_dir_all(config_dir)
.with_context(|| format!("Failed to create config directory: {}", config_dir.display()))?;
let default_config = Self::default_config();
let toml_string = toml::to_string_pretty(&default_config)
.context("Failed to serialize default config")?;
fs::write(&config_path, toml_string)
.with_context(|| format!("Failed to write config file: {}", config_path.display()))?;
eprintln!("Created default config at: {}", config_path.display());
}
Ok(config_path)
}
fn default_config() -> Self {
Config {
paths: vec![
dirs::home_dir()
.unwrap_or_default()
.join("Work/Projects")
.to_string_lossy()
.to_string(),
],
max_depth: 5,
cache_enabled: true,
cache_ttl_hours: 24,
default_session: default_session_config(),
}
}
}
+251
View File
@@ -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(())
}
+267
View File
@@ -0,0 +1,267 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use std::process::Command;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Window {
pub name: String,
#[serde(default)]
pub panes: Vec<String>,
#[serde(default)]
pub layout: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SessionConfig {
#[serde(default)]
pub windows: Vec<Window>,
}
impl SessionConfig {
pub fn load_from_project(project_path: &Path) -> Result<Option<Self>> {
let config_path = project_path.join(".tmuxido.toml");
if !config_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read session config: {}", config_path.display()))?;
let config: SessionConfig = toml::from_str(&content)
.with_context(|| format!("Failed to parse session config: {}", config_path.display()))?;
Ok(Some(config))
}
}
pub struct TmuxSession {
session_name: String,
project_path: String,
base_index: usize,
}
impl TmuxSession {
pub fn new(project_path: &Path) -> Self {
let session_name = project_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("project")
.replace('.', "_")
.replace(' ', "-");
let base_index = Self::get_base_index();
Self {
session_name,
project_path: project_path.display().to_string(),
base_index,
}
}
fn get_base_index() -> usize {
// Try to get base-index from tmux
let output = Command::new("tmux")
.args(["show-options", "-gv", "base-index"])
.output();
if let Ok(output) = output {
if output.status.success() {
let index_str = String::from_utf8_lossy(&output.stdout);
if let Ok(index) = index_str.trim().parse::<usize>() {
return index;
}
}
}
// Default to 0 if we can't determine
0
}
pub fn create(&self, config: &SessionConfig) -> Result<()> {
// Check if we're already inside a tmux session
let inside_tmux = std::env::var("TMUX").is_ok();
// Check if session already exists
let session_exists = Command::new("tmux")
.args(["has-session", "-t", &self.session_name])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if session_exists {
// Session exists, just switch to it
if inside_tmux {
Command::new("tmux")
.args(["switch-client", "-t", &self.session_name])
.status()
.context("Failed to switch to existing session")?;
} else {
Command::new("tmux")
.args(["attach-session", "-t", &self.session_name])
.status()
.context("Failed to attach to existing session")?;
}
return Ok(());
}
// Create new session
if config.windows.is_empty() {
// Create simple session with one window
self.create_simple_session()?;
} else {
// Create session with custom windows
self.create_custom_session(config)?;
}
// Attach or switch to the session
if inside_tmux {
Command::new("tmux")
.args(["switch-client", "-t", &self.session_name])
.status()
.context("Failed to switch to new session")?;
} else {
Command::new("tmux")
.args(["attach-session", "-t", &self.session_name])
.status()
.context("Failed to attach to new session")?;
}
Ok(())
}
fn create_simple_session(&self) -> Result<()> {
// Create a detached session with one window
Command::new("tmux")
.args([
"new-session",
"-d",
"-s",
&self.session_name,
"-c",
&self.project_path,
])
.status()
.context("Failed to create tmux session")?;
Ok(())
}
fn create_custom_session(&self, config: &SessionConfig) -> Result<()> {
// Create session with first window
let first_window = &config.windows[0];
Command::new("tmux")
.args([
"new-session",
"-d",
"-s",
&self.session_name,
"-n",
&first_window.name,
"-c",
&self.project_path,
])
.status()
.context("Failed to create tmux session")?;
// Create panes for first window if specified
if !first_window.panes.is_empty() {
self.create_panes(self.base_index, &first_window.panes)?;
}
// Apply layout for first window if specified
if let Some(layout) = &first_window.layout {
self.apply_layout(self.base_index, layout)?;
}
// Create additional windows
for (index, window) in config.windows.iter().skip(1).enumerate() {
let window_index = self.base_index + index + 1;
Command::new("tmux")
.args([
"new-window",
"-t",
&format!("{}:{}", self.session_name, window_index),
"-n",
&window.name,
"-c",
&self.project_path,
])
.status()
.with_context(|| format!("Failed to create window: {}", window.name))?;
// Create panes if specified
if !window.panes.is_empty() {
self.create_panes(window_index, &window.panes)?;
}
// Apply layout if specified
if let Some(layout) = &window.layout {
self.apply_layout(window_index, layout)?;
}
}
// Select the first window
Command::new("tmux")
.args(["select-window", "-t", &format!("{}:{}", self.session_name, self.base_index)])
.status()
.context("Failed to select first window")?;
Ok(())
}
fn create_panes(&self, window_index: usize, panes: &[String]) -> Result<()> {
for (pane_index, command) in panes.iter().enumerate() {
let target = format!("{}:{}", self.session_name, window_index);
// First pane already exists (created with the window), skip split
if pane_index > 0 {
// Create new pane by splitting
Command::new("tmux")
.args([
"split-window",
"-t",
&target,
"-c",
&self.project_path,
])
.status()
.context("Failed to split pane")?;
}
// Send the command to the pane if it's not empty
if !command.is_empty() {
let pane_target = format!("{}:{}.{}", self.session_name, window_index, pane_index);
Command::new("tmux")
.args([
"send-keys",
"-t",
&pane_target,
command,
"Enter",
])
.status()
.context("Failed to send keys to pane")?;
}
}
Ok(())
}
fn apply_layout(&self, window_index: usize, layout: &str) -> Result<()> {
Command::new("tmux")
.args([
"select-layout",
"-t",
&format!("{}:{}", self.session_name, window_index),
layout,
])
.status()
.with_context(|| format!("Failed to apply layout: {}", layout))?;
Ok(())
}
}