commit 71da4149b88fd1dd2d6eb36b377ff3325ab9148d Author: cinco euzebio Date: Sat Feb 28 19:06:43 2026 -0300 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. 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