From 71da4149b88fd1dd2d6eb36b377ff3325ab9148d Mon Sep 17 00:00:00 2001 From: cinco euzebio Date: Sat, 28 Feb 2026 19:06:43 -0300 Subject: [PATCH] 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. --- .drone.yml | 44 +++ .gitignore | 1 + .tmuxido.toml | 19 ++ .tmuxido.toml.example | 255 ++++++++++++++++ Cargo.lock | 673 ++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 14 + README.md | 153 ++++++++++ install.sh | 29 ++ src/cache.rs | 156 ++++++++++ src/config.rs | 116 ++++++++ src/main.rs | 251 ++++++++++++++++ src/session.rs | 267 +++++++++++++++++ tmuxido.toml.example | 217 ++++++++++++++ 13 files changed, 2195 insertions(+) create mode 100644 .drone.yml create mode 100644 .gitignore create mode 100644 .tmuxido.toml create mode 100644 .tmuxido.toml.example create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 install.sh create mode 100644 src/cache.rs create mode 100644 src/config.rs create mode 100644 src/main.rs create mode 100644 src/session.rs create mode 100644 tmuxido.toml.example diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..4985430 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,44 @@ +kind: pipeline +type: docker +name: release + +trigger: + event: + - tag + +steps: + - name: build-x86_64 + image: messense/rust-musl-cross:x86_64-musl + commands: + - cargo build --release --target x86_64-unknown-linux-musl + - cp target/x86_64-unknown-linux-musl/release/tmuxido tmuxido-x86_64-linux + + - name: build-aarch64 + image: messense/rust-musl-cross:aarch64-musl + commands: + - cargo build --release --target aarch64-unknown-linux-musl + - cp target/aarch64-unknown-linux-musl/release/tmuxido tmuxido-aarch64-linux + + - name: publish + image: alpine + environment: + GITEA_TOKEN: + from_secret: gitea_token + commands: + - apk add --no-cache curl jq + - | + RELEASE_ID=$(curl -fsSL -X POST \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + "https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/releases" \ + -d "{\"tag_name\":\"$DRONE_TAG\",\"name\":\"$DRONE_TAG\",\"body\":\"Release $DRONE_TAG\"}" \ + | jq -r .id) + for ASSET in tmuxido-x86_64-linux tmuxido-aarch64-linux; do + curl -fsSL -X POST \ + -H "Authorization: token $GITEA_TOKEN" \ + "https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/releases/$RELEASE_ID/assets" \ + -F "attachment=@$ASSET" + done + depends_on: + - build-x86_64 + - build-aarch64 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/.tmuxido.toml b/.tmuxido.toml new file mode 100644 index 0000000..f7ca7c5 --- /dev/null +++ b/.tmuxido.toml @@ -0,0 +1,19 @@ +[[windows]] +name = "tmuxido" + +[[windows]] +name = "editor" +layout = "main-horizontal" +panes = [ + "code . ; claude --dangerously-skip-permissions", + "clear", + "clear" +] + +# [[windows]] +# name = "build" +# panes = [] + +# [[windows]] +# name = "git" +# panes = [] diff --git a/.tmuxido.toml.example b/.tmuxido.toml.example new file mode 100644 index 0000000..bc734dd --- /dev/null +++ b/.tmuxido.toml.example @@ -0,0 +1,255 @@ +# ============================================================================ +# Project-specific tmux session configuration +# ============================================================================ +# Place this file as .tmuxido.toml in your project root directory +# +# This configuration will be used when opening this specific project. +# If this file doesn't exist, the global default_session from +# ~/.config/tmuxido/tmuxido.toml will be used. +# +# Compatible with any tmux base-index setting (0 or 1) +# ============================================================================ + +# ============================================================================ +# BASIC EXAMPLE: Single window with one pane +# ============================================================================ +# [[windows]] +# name = "editor" +# panes = [] # Empty = just open a shell in the project directory + +# ============================================================================ +# INTERMEDIATE EXAMPLE: Single window with multiple panes and layout +# ============================================================================ +# This creates the classic layout: +# - Main pane on top (nvim) +# - Two smaller panes below, side by side +# +# [[windows]] +# name = "editor" +# layout = "main-horizontal" +# panes = [ +# "nvim .", # Pane 0: Opens nvim in project root +# "clear", # Pane 1: Shell ready for commands +# "clear" # Pane 2: Another shell +# ] + +# ============================================================================ +# ADVANCED EXAMPLE: Multiple windows for a complete workflow +# ============================================================================ + +# Window 1: Editor with side terminal +[[windows]] +name = "editor" +layout = "main-vertical" +panes = [ + "nvim .", # Main pane: Editor + "clear" # Side pane: Terminal for quick commands +] + +# Window 2: Development server +[[windows]] +name = "server" +panes = [ + "npm run dev" # Auto-start dev server +] + +# Window 3: Git operations +[[windows]] +name = "git" +panes = [ + "git status", # Show current status + "lazygit" # Or use lazygit if installed +] + +# Window 4: Database/Logs +[[windows]] +name = "logs" +layout = "even-horizontal" +panes = [ + "tail -f logs/development.log", + "docker-compose logs -f" +] + +# ============================================================================ +# PRACTICAL EXAMPLES BY PROJECT TYPE +# ============================================================================ + +# --- Frontend React/Vue/Angular Project --- +# [[windows]] +# name = "code" +# layout = "main-horizontal" +# panes = ["nvim .", "clear", "clear"] +# +# [[windows]] +# name = "dev" +# panes = ["npm run dev"] +# +# [[windows]] +# name = "test" +# panes = ["npm run test:watch"] + +# --- Backend API Project --- +# [[windows]] +# name = "editor" +# layout = "main-vertical" +# panes = ["nvim src/", "cargo watch -x run"] # For Rust +# # Or: panes = ["nvim .", "nodemon server.js"] # For Node.js +# # Or: panes = ["nvim .", "python manage.py runserver"] # For Django +# +# [[windows]] +# name = "database" +# panes = ["psql mydb"] # Or mysql, redis-cli, etc +# +# [[windows]] +# name = "logs" +# panes = ["tail -f logs/app.log"] + +# --- Full Stack Project --- +# [[windows]] +# name = "frontend" +# layout = "main-horizontal" +# panes = [ +# "cd frontend && nvim .", +# "cd frontend && npm run dev" +# ] +# +# [[windows]] +# name = "backend" +# layout = "main-horizontal" +# panes = [ +# "cd backend && nvim .", +# "cd backend && cargo run" +# ] +# +# [[windows]] +# name = "database" +# panes = ["docker-compose up postgres redis"] + +# --- DevOps/Infrastructure Project --- +# [[windows]] +# name = "code" +# panes = ["nvim ."] +# +# [[windows]] +# name = "terraform" +# panes = ["terraform plan"] +# +# [[windows]] +# name = "k8s" +# layout = "even-vertical" +# panes = [ +# "kubectl get pods -w", +# "stern -l app=myapp", # Log streaming +# "k9s" # Kubernetes TUI +# ] + +# --- Data Science/ML Project --- +# [[windows]] +# name = "jupyter" +# panes = ["jupyter lab"] +# +# [[windows]] +# name = "editor" +# panes = ["nvim ."] +# +# [[windows]] +# name = "training" +# layout = "even-vertical" +# panes = [ +# "python train.py", +# "watch -n 1 nvidia-smi" # GPU monitoring +# ] + +# ============================================================================ +# AVAILABLE LAYOUTS +# ============================================================================ +# Layout determines how panes are arranged in a window: +# +# main-horizontal: Main pane on top, others stacked below horizontally +# ┌─────────────────────────────┐ +# │ Main Pane │ +# ├──────────────┬──────────────┤ +# │ Pane 2 │ Pane 3 │ +# └──────────────┴──────────────┘ +# +# main-vertical: Main pane on left, others stacked right vertically +# ┌──────────┬──────────┐ +# │ │ Pane 2 │ +# │ Main ├──────────┤ +# │ Pane │ Pane 3 │ +# └──────────┴──────────┘ +# +# tiled: All panes in a grid +# ┌──────────┬──────────┐ +# │ Pane 1 │ Pane 2 │ +# ├──────────┼──────────┤ +# │ Pane 3 │ Pane 4 │ +# └──────────┴──────────┘ +# +# even-horizontal: All panes in a row, equal width +# ┌────┬────┬────┬────┐ +# │ P1 │ P2 │ P3 │ P4 │ +# └────┴────┴────┴────┘ +# +# even-vertical: All panes in a column, equal height +# ┌──────────────┐ +# │ Pane 1 │ +# ├──────────────┤ +# │ Pane 2 │ +# ├──────────────┤ +# │ Pane 3 │ +# └──────────────┘ + +# ============================================================================ +# TIPS & TRICKS +# ============================================================================ +# 1. Commands are executed with "Enter" automatically +# 2. Use "clear" to just open a clean shell +# 3. Commands run in the project directory by default +# 4. Use "cd subdir && command" to run in subdirectories +# 5. First pane in array is pane 0 (uses the window's initial pane) +# 6. Subsequent panes are created by splitting +# 7. Layout is applied after all panes are created +# 8. Empty panes array = single pane window +# 9. You can have as many windows as you want +# 10. Compatible with tmux base-index 0 or 1 (auto-detected) + +# ============================================================================ +# COMMON PATTERNS +# ============================================================================ + +# Pattern: Editor + horizontal terminal split +# [[windows]] +# name = "work" +# layout = "main-horizontal" +# panes = ["nvim .", "clear"] + +# Pattern: Vertical split with commands side by side +# [[windows]] +# name = "dev" +# layout = "even-vertical" +# panes = ["npm run dev", "npm run test:watch"] + +# Pattern: Monitoring dashboard +# [[windows]] +# name = "monitor" +# layout = "tiled" +# panes = [ +# "htop", +# "watch -n 1 df -h", +# "tail -f /var/log/syslog", +# "docker stats" +# ] + +# Pattern: Simple workflow (no special layout needed) +# [[windows]] +# name = "code" +# panes = [] +# +# [[windows]] +# name = "run" +# panes = [] +# +# [[windows]] +# name = "git" +# panes = [] diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2b48d96 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,673 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c2cfd7bf8a6017ddaa4e32ffe7403d547790db06bd171c1c53926faab501623" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a4c05b9e80c5ccd3a7ef080ad7b6ba7d6fc00a985b8b157197075677c82c7a0" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom", + "libredox", + "thiserror 2.0.17", +] + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "shellexpand" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" +dependencies = [ + "dirs 6.0.0", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tmuxido" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "dirs 5.0.1", + "serde", + "serde_json", + "shellexpand", + "toml", + "walkdir", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e818f1d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "tmuxido" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +toml = "0.8" +dirs = "5.0" +walkdir = "2.4" +anyhow = "1.0" +shellexpand = "3.1" +clap = { version = "4.5", features = ["derive"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..c3375c8 --- /dev/null +++ b/README.md @@ -0,0 +1,153 @@ +# tmuxido + +A Rust-based tool to quickly find and open projects in tmux using fzf. No external dependencies except tmux and fzf! + +## Features + +- Search for git repositories in configurable paths +- Interactive selection using fzf +- Native tmux session creation (no tmuxinator required!) +- Support for project-specific `.tmuxido.toml` configs +- Smart session switching (reuses existing sessions) +- TOML-based configuration +- Smart caching system for fast subsequent runs +- Configurable cache TTL +- Zero external dependencies (except tmux and fzf) + +## Installation + +```sh +curl -fsSL https://git.cincoeuzebio.com/cinco/Tmuxido/raw/branch/main/install.sh | sh +``` + +Installs the latest release binary to `~/.local/bin/tmuxido`. On first run, the config file is created automatically at `~/.config/tmuxido/tmuxido.toml`. + +### Build from source + +```bash +cargo build --release +cp target/release/tmuxido ~/.local/bin/ +``` + +## Configuration + +The configuration file is located at `~/.config/tmuxido/tmuxido.toml`. + +On first run, a default configuration will be created automatically. + +Example configuration: +```toml +# List of paths where to search for projects (git repositories) +paths = [ + "~/Projects", +] + +# Maximum depth to search for .git directories +max_depth = 5 + +# Enable project caching (default: true) +cache_enabled = true + +# Cache TTL in hours (default: 24) +cache_ttl_hours = 24 + +# Default session configuration (used when project has no .tmuxido.toml) +[default_session] + +[[default_session.windows]] +name = "editor" +panes = [] + +[[default_session.windows]] +name = "terminal" +panes = [] +``` + +## Usage + +Run without arguments to search all configured paths and select with fzf: +```bash +tmuxido +``` + +Or provide a specific directory: +```bash +tmuxido /path/to/project +``` + +Force refresh the cache (useful after adding new projects): +```bash +tmuxido --refresh +# or +tmuxido -r +``` + +Check cache status: +```bash +tmuxido --cache-status +``` + +View help: +```bash +tmuxido --help +``` + +## Requirements + +- [tmux](https://github.com/tmux/tmux) - Terminal multiplexer +- [fzf](https://github.com/junegunn/fzf) - For interactive selection + +## How it works + +1. Searches for git repositories (directories containing `.git`) in configured paths +2. Caches the results for faster subsequent runs +3. Presents them using fzf for selection +4. Creates or switches to a tmux session for the selected project +5. If a `.tmuxido.toml` config exists in the project, uses it to set up custom windows and panes +6. Otherwise, creates a default session with two windows: "editor" and "terminal" + +## Caching + +The tool uses an incremental cache to keep subsequent runs fast: + +- **Cache location**: `~/.cache/tmuxido/projects.json` +- **Incremental updates**: On each run, only directories whose mtime changed are rescanned — no full rescans +- **Manual refresh**: Use `--refresh` to force a full rescan +- **Cache status**: Use `--cache-status` to inspect the cache + +The cache persists indefinitely and is updated automatically when the filesystem changes. + +## Project-specific Configuration + +You can customize the tmux session layout for individual projects by creating a `.tmuxido.toml` file in the project root. + +Example `.tmuxido.toml`: +```toml +[[windows]] +name = "editor" +panes = ["nvim"] +layout = "main-horizontal" + +[[windows]] +name = "server" +panes = ["npm run dev"] + +[[windows]] +name = "git" +panes = [] +``` + +### Available Layouts + +- `main-horizontal` - Main pane on top, others below +- `main-vertical` - Main pane on left, others on right +- `tiled` - All panes tiled +- `even-horizontal` - All panes in horizontal row +- `even-vertical` - All panes in vertical column + +### Panes + +Each window can have multiple panes with commands that run automatically: +- First pane is the main window pane +- Additional panes are created by splitting +- Empty panes array = just open the window in the project directory diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..ae2e61b --- /dev/null +++ b/install.sh @@ -0,0 +1,29 @@ +#!/bin/sh +set -e + +REPO="cinco/Tmuxido" +BASE_URL="https://git.cincoeuzebio.com" +INSTALL_DIR="$HOME/.local/bin" + +arch=$(uname -m) +case "$arch" in + x86_64) file="tmuxido-x86_64-linux" ;; + aarch64|arm64) file="tmuxido-aarch64-linux" ;; + *) echo "Unsupported architecture: $arch" >&2; exit 1 ;; +esac + +tag=$(curl -fsSL "$BASE_URL/api/v1/repos/$REPO/releases?limit=1&page=1" \ + | grep -o '"tag_name":"[^"]*"' | head -1 | cut -d'"' -f4) + +[ -z "$tag" ] && { echo "Could not fetch latest release" >&2; exit 1; } + +echo "Installing tmuxido $tag..." +mkdir -p "$INSTALL_DIR" +curl -fsSL "$BASE_URL/$REPO/releases/download/$tag/$file" -o "$INSTALL_DIR/tmuxido" +chmod +x "$INSTALL_DIR/tmuxido" +echo "Installed: $INSTALL_DIR/tmuxido" + +case ":$PATH:" in + *":$INSTALL_DIR:"*) ;; + *) echo "Note: add $INSTALL_DIR to your PATH (e.g. export PATH=\"\$HOME/.local/bin:\$PATH\")" ;; +esac diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..7d8695e --- /dev/null +++ b/src/cache.rs @@ -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, + 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, +} + +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 { + dirs.iter() + .filter(|dir| !dirs.iter().any(|other| other != *dir && dir.starts_with(other))) + .cloned() + .collect() +} + +impl ProjectCache { + pub fn new(projects: Vec, dir_mtimes: HashMap) -> Self { + Self { + projects, + last_updated: now_secs(), + dir_mtimes, + } + } + + pub fn cache_path() -> Result { + 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> { + 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, HashMap)>, + ) -> Result { + 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 = 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) + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..8996944 --- /dev/null +++ b/src/config.rs @@ -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, + #[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 { + 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 { + 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 { + 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(), + } + } + +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b12b1cf --- /dev/null +++ b/src/main.rs @@ -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, + + /// 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> { + 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, HashMap)> { + 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, HashMap)> { + 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 { + 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(()) +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..ba8c829 --- /dev/null +++ b/src/session.rs @@ -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, + #[serde(default)] + pub layout: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SessionConfig { + #[serde(default)] + pub windows: Vec, +} + +impl SessionConfig { + pub fn load_from_project(project_path: &Path) -> Result> { + 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::() { + 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(()) + } +} diff --git a/tmuxido.toml.example b/tmuxido.toml.example new file mode 100644 index 0000000..5728699 --- /dev/null +++ b/tmuxido.toml.example @@ -0,0 +1,217 @@ +# ============================================================================ +# tmuxido - Global Configuration +# ============================================================================ +# Location: ~/.config/tmuxido/tmuxido.toml +# +# This is the main configuration file that controls: +# 1. Where to search for projects +# 2. Caching behavior +# 3. Default session layout (used when projects don't have .tmuxido.toml) +# +# Compatible with any tmux base-index setting (0 or 1, auto-detected) +# ============================================================================ + +# ============================================================================ +# PROJECT DISCOVERY +# ============================================================================ +# Paths where tmuxido will search for git repositories +# Supports ~ for home directory expansion + +paths = [ + "~/Projects", + # "~/opensource", + # "~/clients/company-name", +] + +# Maximum directory depth to search for .git folders +# Higher values = slower scan, but finds deeply nested projects +# Lower values = faster scan, but might miss some projects +# Default: 5 +max_depth = 5 + +# ============================================================================ +# CACHING CONFIGURATION +# ============================================================================ +# Caching dramatically speeds up subsequent runs by storing discovered projects + +# Enable/disable project caching +# Default: true +cache_enabled = true + +# How long (in hours) before cache is considered stale and refreshed +# Set lower if you frequently add new projects +# Set higher if your projects are stable +# Default: 24 +cache_ttl_hours = 24 + +# Cache location: ~/.cache/tmuxido/projects.json +# Use --refresh flag to force cache update +# Use --cache-status to see cache information + +# ============================================================================ +# DEFAULT SESSION CONFIGURATION +# ============================================================================ +# This configuration is used for projects that don't have their own +# .tmuxido.toml file in the project root. +# +# You can customize this to match your preferred workflow! +# ============================================================================ + +[default_session] + +# --- OPTION 1: Simple two-window setup (CURRENT DEFAULT) --- +[[default_session.windows]] +name = "editor" +panes = [] + +[[default_session.windows]] +name = "terminal" +panes = [] + +# --- OPTION 2: Single window with nvim and terminal split --- +# Uncomment this and comment out Option 1 above +# [[default_session.windows]] +# name = "work" +# layout = "main-horizontal" +# panes = [ +# "nvim .", # Main pane: Editor +# "clear", # Bottom left: Terminal +# "clear" # Bottom right: Terminal +# ] + +# --- OPTION 3: Three-window workflow (code, run, git) --- +# Uncomment this and comment out Option 1 above +# [[default_session.windows]] +# name = "code" +# layout = "main-vertical" +# panes = ["nvim .", "clear"] +# +# [[default_session.windows]] +# name = "run" +# panes = [] +# +# [[default_session.windows]] +# name = "git" +# panes = [] + +# --- OPTION 4: Full development setup --- +# Uncomment this and comment out Option 1 above +# [[default_session.windows]] +# name = "editor" +# layout = "main-horizontal" +# panes = ["nvim .", "clear", "clear"] +# +# [[default_session.windows]] +# name = "server" +# panes = [] +# +# [[default_session.windows]] +# name = "logs" +# panes = [] +# +# [[default_session.windows]] +# name = "git" +# panes = ["git status"] + +# ============================================================================ +# AVAILABLE LAYOUTS +# ============================================================================ +# Use these layout values in your windows: +# +# main-horizontal: Main pane on top, others below +# ┌─────────────────────────────┐ +# │ Main Pane │ +# ├──────────────┬──────────────┤ +# │ Pane 2 │ Pane 3 │ +# └──────────────┴──────────────┘ +# +# main-vertical: Main pane on left, others on right +# ┌──────────┬──────────┐ +# │ │ Pane 2 │ +# │ Main ├──────────┤ +# │ Pane │ Pane 3 │ +# └──────────┴──────────┘ +# +# tiled: All panes in a grid +# ┌──────────┬──────────┐ +# │ Pane 1 │ Pane 2 │ +# ├──────────┼──────────┤ +# │ Pane 3 │ Pane 4 │ +# └──────────┴──────────┘ +# +# even-horizontal: All panes in equal-width columns +# ┌────┬────┬────┬────┐ +# │ P1 │ P2 │ P3 │ P4 │ +# └────┴────┴────┴────┘ +# +# even-vertical: All panes in equal-height rows +# ┌──────────────┐ +# │ Pane 1 │ +# ├──────────────┤ +# │ Pane 2 │ +# ├──────────────┤ +# │ Pane 3 │ +# └──────────────┘ + +# ============================================================================ +# USAGE EXAMPLES +# ============================================================================ +# Run without arguments (uses fzf to select project): +# $ tmuxido +# +# Open specific project directly: +# $ tmuxido /path/to/project +# +# Force refresh cache (after adding new projects): +# $ tmuxido --refresh +# $ tmuxido -r +# +# Check cache status: +# $ tmuxido --cache-status +# +# Show help: +# $ tmuxido --help + +# ============================================================================ +# PROJECT-SPECIFIC OVERRIDES +# ============================================================================ +# To customize a specific project, create .tmuxido.toml in that +# project's root directory. See .tmuxido.toml.example for details. +# +# Hierarchy: +# 1. Project's .tmuxido.toml (highest priority) +# 2. Global [default_session] from this file (fallback) + +# ============================================================================ +# TIPS & BEST PRACTICES +# ============================================================================ +# 1. Start with the simple Option 1 default, customize as you learn +# 2. Use project-specific configs for special projects (web apps, etc) +# 3. Set cache_ttl_hours lower (6-12) if you frequently add projects +# 4. Add multiple paths to organize personal vs work vs open-source +# 5. Use max_depth wisely - higher isn't always better (slower scans) +# 6. Run --cache-status to verify your settings are working +# 7. The tool auto-detects your tmux base-index (0 or 1), no config needed +# 8. Empty panes = shell in project directory (fastest to open) +# 9. Commands in panes run automatically when session is created +# 10. Use "clear" in panes for clean shells without running commands + +# ============================================================================ +# TROUBLESHOOTING +# ============================================================================ +# Projects not showing up? +# - Check that paths exist and contain .git directories +# - Increase max_depth if projects are nested deeper +# - Run with --refresh to force cache update +# +# Cache seems stale? +# - Run tmuxido --refresh +# - Lower cache_ttl_hours value +# +# Windows/panes not created correctly? +# - Tool auto-detects base-index, but verify with: tmux show-options -g base-index +# - Check TOML syntax in default_session or project config +# +# Want to reset to defaults? +# - Delete this file, it will be recreated on next run +# - Or copy from: /path/to/repo/tmuxido.toml.example