Compare commits

...

No commits in common. "main" and "badges" have entirely different histories.
main ... badges

41 changed files with 1 additions and 7761 deletions

View File

@ -1,16 +0,0 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash -c 'if [ -f Cargo.toml ]; then if command -v cargo-nextest >/dev/null 2>&1; then cargo nextest run 2>&1; else cargo test 2>&1; fi; fi'",
"timeout": 120
}
]
}
]
}
}

View File

@ -1,3 +0,0 @@
target/
.git/
.claude/

View File

@ -1,153 +0,0 @@
kind: pipeline
type: docker
name: ci
trigger:
event:
- push
- pull_request
steps:
- name: test
image: rust:latest
commands:
- cargo fmt --check
- cargo clippy -- -D warnings
- cargo test
- name: coverage
image: xd009642/tarpaulin
privileged: true
environment:
GITEA_TOKEN:
from_secret: gitea_token
commands:
- |
apt-get update -qq && apt-get install -y -qq jq curl
PCT=$(cargo tarpaulin 2>&1 | awk '/coverage,/{print int($1)}')
[ -z "$PCT" ] && PCT=0
if [ "$PCT" -ge 80 ]; then FILL="#4c1"
elif [ "$PCT" -ge 60 ]; then FILL="#dfb317"
else FILL="#e05d44"; fi
echo "PCT=$PCT FILL=$FILL"
printf '<svg xmlns="http://www.w3.org/2000/svg" width="120" height="20"><rect width="76" height="20" fill="#555"/><rect x="76" width="44" height="20" fill="%s"/><g fill="#fff" text-anchor="middle" font-family="sans-serif" font-size="11"><text x="38" y="14">coverage</text><text x="98" y="14">%s%%</text></g></svg>' "$FILL" "$PCT" > coverage.svg
CONTENT=$(base64 coverage.svg | tr -d '\n')
SHA=$(curl -s -H "Authorization: token $GITEA_TOKEN" \
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/contents/coverage.svg?ref=badges" \
| jq -r '.sha')
jq -n --arg msg "ci: update coverage badge [CI SKIP]" \
--arg content "$CONTENT" --arg sha "$SHA" --arg branch "badges" \
'{message: $msg, content: $content, sha: $sha, branch: $branch}' \
| curl -fsSL -X PUT \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/contents/coverage.svg" \
-d @-
---
kind: pipeline
type: docker
name: release
trigger:
event:
- tag
ref:
- refs/tags/[0-9]*
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
- |
# Delete existing release for this tag if present (handles retag scenarios)
EXISTING_ID=$(curl -fsSL \
-H "Authorization: token $GITEA_TOKEN" \
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/releases/tags/$DRONE_TAG" \
| jq -r '.id // empty')
if [ -n "$EXISTING_ID" ]; then
curl -fsSL -X DELETE \
-H "Authorization: token $GITEA_TOKEN" \
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/releases/$EXISTING_ID"
fi
# Read DRONE_TAG via ENVIRON inside awk to avoid Drone's ${VAR} substitution
# which would replace ${TAG} with an empty string before the shell runs.
BODY=$(awk '
BEGIN { tag = ENVIRON["DRONE_TAG"] }
/^## \[/ { in_section = (index($0, "[" tag "]") > 0); next }
in_section && /^## \[/ { exit }
in_section { print }
' CHANGELOG.md)
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\":$(echo "$BODY" | jq -Rs .)}" \
| 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
- name: publish-github
image: alpine
environment:
GITHUB_TOKEN:
from_secret: GITHUB_TOKEN
GITHUB_REPO: cinco/tmuxido
commands:
- apk add --no-cache curl jq
- |
EXISTING=$(curl -fsSL \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/$GITHUB_REPO/releases/tags/$DRONE_TAG" | jq -r '.id // empty')
if [ -n "$EXISTING" ]; then
curl -s -X DELETE \
-H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/repos/$GITHUB_REPO/releases/$EXISTING"
fi
BODY=$(awk '
BEGIN { tag = ENVIRON["DRONE_TAG"] }
/^## \[/ { in_section = (index($0, "[" tag "]") > 0); next }
in_section && /^## \[/ { exit }
in_section { print }
' CHANGELOG.md)
RELEASE_ID=$(curl -fsSL -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
-H "Content-Type: application/json" \
"https://api.github.com/repos/$GITHUB_REPO/releases" \
-d "{\"tag_name\":\"$DRONE_TAG\",\"name\":\"$DRONE_TAG\",\"body\":$(echo "$BODY" | jq -Rs .)}" \
| jq -r '.id')
for FILE in tmuxido-x86_64-linux tmuxido-aarch64-linux; do
curl -fsSL -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Content-Type: application/octet-stream" \
"https://uploads.github.com/repos/$GITHUB_REPO/releases/$RELEASE_ID/assets?name=$FILE" \
--data-binary @"$FILE"
done
depends_on:
- build-x86_64
- build-aarch64

1
.gitignore vendored
View File

@ -1 +0,0 @@
/target

View File

@ -1,24 +0,0 @@
{
"mcpServers": {
"rust-mcp": {
"type": "stdio",
"command": "rust-mcp-server",
"args": []
},
"crates": {
"type": "stdio",
"command": "crates-mcp",
"args": []
},
"drone-ci-mcp": {
"command": "npx",
"args": [
"-y",
"drone-ci-mcp",
"--access-token=${DRONE_TOKEN}",
"--server-url=https://drone.cincoeuzebio.com"
]
}
}
}

View File

@ -1,19 +0,0 @@
[[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 = []

View File

@ -1,255 +0,0 @@
# ============================================================================
# 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 = []

View File

@ -1,187 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [0.10.0] - 2026-03-05
### Added
- fzf preview panel: hovering over a project shows its `README.md` in the right 40% of the screen
- Uses `glow` for rendered markdown when available, falls back to `cat`
- `CLICOLOR_FORCE=1` ensures glow outputs full ANSI colors even in fzf's non-TTY preview pipe
- Preview command runs via `sh -c '...' -- {}` for compatibility with fish, zsh, and bash
## [0.9.2] - 2026-03-04
### Changed
- Cache now uses stale-while-revalidate: cached projects are returned immediately and a background process (`--background-refresh`) rebuilds the cache when it is stale, eliminating blocking scans on every invocation
- `cache_ttl_hours` is now enforced: when the cache age exceeds the configured TTL, a background refresh is triggered automatically
## [0.9.1] - 2026-03-01
### Fixed
- Shortcut and desktop integration wizards are now offered regardless of whether the user chose the interactive wizard or the default config on first run; previously they were only offered in the wizard path
## [0.9.0] - 2026-03-01
### Added
- First-run setup choice prompt: when no configuration file exists, tmuxido now asks whether to run the interactive wizard or apply sensible defaults immediately
- `SetupChoice` enum and `parse_setup_choice_input` in `ui` module (pure, fully tested)
- `Config::write_default_config` helper for writing defaults without any prompts
- `Config::run_wizard` extracted from `ensure_config_exists` for clarity and testability
- `render_setup_choice_prompt` and `render_default_config_saved` render functions
## [0.8.3] - 2026-03-01
### Fixed
- `Cargo.lock` now committed alongside version bumps
## [0.8.2] - 2026-03-01
### Fixed
- `install.sh`: grep pattern for `tag_name` now handles the space GitHub includes after the colon in JSON (`"tag_name": "x"` instead of `"tag_name":"x"`)
## [0.8.1] - 2026-03-01
### Fixed
- `install.sh`: removed `-f` flag from GitHub API `curl` call so HTTP error responses (rate limits, 404s) are printed instead of silently discarded; shows up to 400 bytes of the raw API response when the release tag cannot be parsed
## [0.8.0] - 2026-03-01
### Added
- Keyboard shortcut setup wizard on first run and via `tmuxido --setup-shortcut`
- Auto-detects desktop environment from `XDG_CURRENT_DESKTOP` / `HYPRLAND_INSTANCE_SIGNATURE`
- Hyprland: appends `bindd` entry to `~/.config/hypr/bindings.conf`; prefers `omarchy-launch-tui` when available, falls back to `xdg-terminal-exec`
- GNOME: registers a custom keybinding via `gsettings`
- KDE: appends a `[tmuxido]` section to `~/.config/kglobalshortcutsrc`
- Conflict detection per DE (Hyprland via `hyprctl binds -j`, KDE via config file, GNOME via gsettings); suggests next free combo from a fallback list
- `--setup-desktop-shortcut` flag to (re-)install the `.desktop` entry and icon at any time
- `shortcut` module (`src/shortcut.rs`) with full unit and integration test coverage
- Icon and `.desktop` file installed by `install.sh` and offered in the first-run wizard
## [0.7.1] - 2026-03-01
### Fixed
- Interactive setup wizard now asks for a tmux layout when a window has 2 or more panes
- Layout selection shown in post-wizard summary
### Changed
- README: Added ASCII art previews for each available tmux layout
## [0.7.0] - 2026-03-01
### Changed
- `install.sh` now downloads from GitHub Releases
- Self-update now queries the GitHub Releases API for new versions
- Releases are published to both Gitea and GitHub
## [0.6.0] - 2026-03-01
### Added
- Periodic update check: on startup, if `update_check_interval_hours` have elapsed since
the last check, tmuxido fetches the latest release tag from the Gitea API and prints a
notice when a newer version is available (silent on network failure or no update found)
- New `update_check` module (`src/update_check.rs`) with injected fetcher for testability
- `update_check_interval_hours` config field (default 24, set to 0 to disable)
- Cache file `~/.cache/tmuxido/update_check.json` tracks last-checked timestamp and
latest known version across runs
## [0.5.2] - 2026-03-01
### Added
- Test for `detect_arch` asserting asset name follows `tmuxido-{arch}-linux` format
## [0.5.1] - 2026-03-01
### Fixed
- Tmux window creation now targets windows by name instead of numeric index, eliminating
"index in use" and "can't find window" errors when `base-index` is not 0
- Self-update asset name corrected from `x86_64-linux` to `tmuxido-x86_64-linux` to match
what CI actually uploads, fixing 404 on `--update`
- CI release pipeline now deletes any existing release for the tag before recreating,
preventing 409 Conflict errors on retagged releases
## [0.5.0] - 2026-03-01
### Added
- Interactive configuration wizard on first run with styled prompts
- `lipgloss` dependency for beautiful terminal UI with Tokyo Night theme colors
- Emoji-enhanced prompts and feedback during setup
- Configure project paths interactively with comma-separated input
- Configure `max_depth` for project discovery scanning
- Configure cache settings (`cache_enabled`, `cache_ttl_hours`)
- Configure default session windows interactively
- Configure panes within each window with custom names
- Configure startup commands for each pane (e.g., `nvim .`, `npm run dev`)
- New `ui` module with styled render functions for all prompts
- Comprehensive summary showing all configured settings after setup
## [0.4.2] - 2026-03-01
### Fixed
- Version mismatch: bumped Cargo.toml version to match release tag, fixing `--update` false positive
## [0.4.1] - 2026-03-01
### Added
- Self-update feature (`tmuxido --update`) to update binary from latest GitHub release
## [0.4.0] - 2026-03-01
### Added
- Self-update feature (`tmuxido --update`) to update binary from latest GitHub release
- New `self_update` module with version comparison and atomic binary replacement
- `--update` CLI flag for in-place binary updates
- Backup and rollback mechanism if update fails
## [0.3.0] - 2026-03-01
### Added
- Dependency check for `fzf` and `tmux` at startup, before any operation
- Automatic Linux package manager detection (apt, pacman, dnf, yum, zypper, emerge, xbps, apk)
- Interactive installation prompt when required tools are missing
- `deps` module with injectable `BinaryChecker` trait for unit testing without hitting the real system
- Integration tests in `tests/deps.rs` (11 tests using real `SystemBinaryChecker`)
- Docker test suite in `tests/docker/` with 15 scenarios simulating a fresh Ubuntu 24.04 user
### Fixed
- Release pipeline `publish` step now reads `DRONE_TAG` via awk `ENVIRON` to prevent Drone's
`${VAR}` substitution from wiping local shell variables before the shell runs
## [0.2.4] - 2026-03-01
### Fixed
- Coverage percentage calculation in CI (correct field from tarpaulin JSON output)
- Release pipeline trigger now matches `v*` tag format instead of `[0-9]*`
## [0.2.2] - 2026-02-28
### Added
- Coverage badge generated by `cargo-tarpaulin` in CI, hosted in Gitea Generic Package Registry
- CI status, coverage, version, and Rust edition badges in README
## [0.2.1] - 2026-02-28
### Added
- Drone CI pipeline (`ci`) running `cargo fmt --check`, `cargo clippy`, and `cargo test` on every push and pull request
## [0.2.0] - 2026-02-28
### Added
- Unit tests for `cache`, `session`, and `config` modules
- Integration tests for scan, session config, and cache lifecycle
### Changed
- Refactored business logic into `lib.rs` for better testability; `main.rs` is now a thin entrypoint
## [0.1.1] - 2026-02-28
### Fixed
- Removed personal path references from default configuration and examples
## [0.1.0] - 2026-02-28
### Added
- Initial release of tmuxido

121
CLAUDE.md
View File

@ -1,121 +0,0 @@
# Rust Project — Claude Instructions
## Mandatory Rules
1. **Always write tests** alongside production code — no feature ships without tests
2. **Always verify tests pass** after every change — the PostToolUse hook runs automatically;
if it shows failures, fix them before moving on
3. Run `cargo clippy -- -D warnings` and resolve all warnings
4. Run `cargo fmt` before considering any task complete
## Available MCP Tools
Install with `curl -sSf https://raw.githubusercontent.com/USUARIO/claude-rust-scaffold/main/install.sh | sh`
| Server | Tools | Purpose |
|--------|-------|---------|
| `rust-mcp` | `cargo_check`, `cargo_build`, `cargo_test`, `cargo_clippy`, `cargo_fmt`, `cargo_add` | Run cargo commands directly |
| `crates` | search, versions, dependencies, docs | Explore crates.io and docs.rs |
## Test Structure
### Unit Tests — inside `src/`
Place at the bottom of each source file:
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_return_error_when_input_is_empty() {
// arrange
let input = "";
// act
let result = parse(input);
// assert
assert!(result.is_err());
}
}
```
- Name tests descriptively: `should_<outcome>_when_<condition>`
- Cover: happy path, edge cases (empty, max values), error cases
### Integration Tests — `tests/` directory
- One file per feature or behavior
- Use only public interfaces (`pub`)
- Simulate real usage end-to-end
```rust
// tests/parsing.rs
use tmuxido::parse;
#[test]
fn parses_valid_input_successfully() {
let result = parse("valid input");
assert!(result.is_ok());
}
```
### Snapshot Testing with `insta`
For complex outputs or large structs:
```rust
#[test]
fn renders_report_correctly() {
let report = generate_report(&data);
insta::assert_snapshot!(report);
}
```
Review snapshots: `cargo insta review`
### Property Testing with `proptest`
For pure functions over wide input domains:
```rust
use proptest::prelude::*;
proptest! {
#[test]
fn round_trip_encode_decode(s in ".*") {
let encoded = encode(&s);
prop_assert_eq!(decode(&encoded), s);
}
}
```
## Recommended `Cargo.toml` dev-dependencies
```toml
[dev-dependencies]
proptest = "1"
insta = { version = "1", features = ["json", "yaml"] }
mockall = "0.13"
# if async:
tokio = { version = "1", features = ["full", "test-util"] }
```
## Recommended Project Structure
```
tmuxido/
├── Cargo.toml
├── src/
│ ├── lib.rs # core logic (unit tests at bottom)
│ ├── main.rs # entrypoint (thin, delegates to lib)
│ └── module/
│ └── mod.rs # #[cfg(test)] mod tests {} at bottom
├── tests/
│ └── integration.rs # integration tests
└── benches/
└── bench.rs # benchmarks (optional)
```
Prefer `lib.rs` + `main.rs` split so logic stays testable independently of the binary entrypoint.

1235
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +0,0 @@
[package]
name = "tmuxido"
version = "0.10.0"
edition = "2024"
[dev-dependencies]
tempfile = "3"
[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"] }
lipgloss = "0.1"

247
README.md
View File

@ -1,247 +0,0 @@
<div align="center">
<img src="docs/assets/tmuxido-logo.png" alt="tmuxido logo" width="200"/>
</div>
<div align="center">
[![Build Status](https://drone.cincoeuzebio.com/api/badges/cinco/Tmuxido/status.svg)](https://drone.cincoeuzebio.com/cinco/Tmuxido)
[![Coverage](https://git.cincoeuzebio.com/cinco/Tmuxido/raw/branch/badges/coverage.svg)](https://drone.cincoeuzebio.com/cinco/Tmuxido)
[![Version](https://img.shields.io/gitea/v/release/cinco/Tmuxido?gitea_url=https%3A%2F%2Fgit.cincoeuzebio.com&label=version)](https://git.cincoeuzebio.com/cinco/Tmuxido/releases)
![Rust 2026](https://img.shields.io/badge/rust-edition_2026-orange?logo=rust)
</div>
# 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 with live `README.md` preview (rendered via `glow` when available)
- Native tmux session creation (no tmuxinator required!)
- Support for project-specific `.tmuxido.toml` configs
- Smart session switching (reuses existing sessions)
- TOML-based configuration
- Smart caching system for fast subsequent runs
- Configurable cache TTL
- Self-update capability (`tmuxido --update`)
- Keyboard shortcut setup for Hyprland, GNOME, and KDE (`tmuxido --setup-shortcut`)
- Zero external dependencies (except tmux and fzf)
## Installation
```sh
curl -fsSL https://raw.githubusercontent.com/cinco/tmuxido/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",
# "~/work",
]
# 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
```
Update tmuxido to the latest version:
```bash
tmuxido --update
```
Set up a desktop keyboard shortcut (Hyprland, GNOME, KDE):
```bash
tmuxido --setup-shortcut
```
Install the `.desktop` entry and icon (so tmuxido appears in app launchers like Walker/Rofi):
```bash
tmuxido --setup-desktop-shortcut
```
Both are also offered automatically on first run. Re-run them any time to reconfigure.
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, uses the default session config from `~/.config/tmuxido/tmuxido.toml` (configured interactively on first run)
## 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 pane │
│ │
├──────────┬───────────┤
│ pane 2 │ pane 3 │
└──────────┴───────────┘
```
**`main-vertical`** — Main pane on left, others on right
```
┌─────────────┬────────┐
│ │ pane 2 │
│ main pane ├────────┤
│ │ pane 3 │
│ ├────────┤
│ │ pane 4 │
└─────────────┴────────┘
```
**`tiled`** — All panes tiled equally
```
┌───────────┬──────────┐
│ pane 1 │ pane 2 │
├───────────┼──────────┤
│ pane 3 │ pane 4 │
└───────────┴──────────┘
```
**`even-horizontal`** — All panes side by side
```
┌────────┬────────┬────────┐
│ │ │ │
│ pane 1 │ pane 2 │ pane 3 │
│ │ │ │
└────────┴────────┴────────┘
```
**`even-vertical`** — All panes stacked
```
┌──────────────────────┐
│ pane 1 │
├──────────────────────┤
│ pane 2 │
├──────────────────────┤
│ pane 3 │
└──────────────────────┘
```
### 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
## Author
<div align="center">
<a href="https://github.com/cinco">
<img src="https://github.com/cinco.png" width="100" height="100" style="border-radius: 50%;" alt="Cinco avatar"/>
</a>
<br><br>
<strong>Cinco</strong>
<br>
<a href="https://github.com/cinco">@cinco</a>
</div>

1
coverage.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="20"><rect width="76" height="20" fill="#555"/><rect x="76" width="44" height="20" fill="#e05d44"/><g fill="#fff" text-anchor="middle" font-family="sans-serif" font-size="11"><text x="38" y="14">coverage</text><text x="98" y="14">44%</text></g></svg>

After

Width:  |  Height:  |  Size: 309 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

View File

@ -1,63 +0,0 @@
#!/bin/sh
set -e
REPO="cinco/tmuxido"
BASE_URL="https://github.com"
RAW_URL="https://raw.githubusercontent.com/$REPO/refs/heads/main"
API_URL="https://api.github.com"
INSTALL_DIR="$HOME/.local/bin"
ICON_DIR="$HOME/.local/share/icons/hicolor/96x96/apps"
DESKTOP_DIR="$HOME/.local/share/applications"
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
api_resp=$(curl -sSL \
-H "Accept: application/vnd.github.v3+json" \
"$API_URL/repos/$REPO/releases/latest")
tag=$(printf '%s' "$api_resp" | grep -o '"tag_name": *"[^"]*"' | grep -o '"[^"]*"$' | tr -d '"')
if [ -z "$tag" ]; then
echo "Could not fetch latest release." >&2
printf 'GitHub API response: %s\n' "$api_resp" | head -c 400 >&2
exit 1
fi
echo "Installing tmuxido $tag..."
# Binary
mkdir -p "$INSTALL_DIR"
curl -fsSL "$BASE_URL/$REPO/releases/download/$tag/$file" -o "$INSTALL_DIR/tmuxido"
chmod +x "$INSTALL_DIR/tmuxido"
echo " binary → $INSTALL_DIR/tmuxido"
# Icon (96×96)
mkdir -p "$ICON_DIR"
curl -fsSL "$RAW_URL/docs/assets/tmuxido-icon_96.png" -o "$ICON_DIR/tmuxido.png"
echo " icon → $ICON_DIR/tmuxido.png"
# .desktop entry
mkdir -p "$DESKTOP_DIR"
curl -fsSL "$RAW_URL/tmuxido.desktop" -o "$DESKTOP_DIR/tmuxido.desktop"
echo " desktop → $DESKTOP_DIR/tmuxido.desktop"
# Refresh desktop database if available
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true
fi
# Refresh icon cache if available
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -f -t "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
fi
case ":$PATH:" in
*":$INSTALL_DIR:"*) ;;
*) echo "Note: add $INSTALL_DIR to your PATH (e.g. export PATH=\"\$HOME/.local/bin:\$PATH\")" ;;
esac
echo "Done! Run 'tmuxido' to get started."

View File

@ -1,3 +0,0 @@
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]

View File

@ -1,268 +0,0 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Serialize, Deserialize)]
pub struct ProjectCache {
pub projects: Vec<PathBuf>,
pub last_updated: u64,
/// mtime de cada diretório visitado durante o scan.
/// Usado para detectar mudanças incrementais sem precisar varrer tudo.
#[serde(default)]
pub dir_mtimes: HashMap<PathBuf, u64>,
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
fn mtime_secs(time: SystemTime) -> u64 {
time.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
/// Retorna o subconjunto mínimo de diretórios: aqueles que não têm nenhum
/// ancestral também na lista. Evita rescanear a mesma subárvore duas vezes.
pub(crate) fn minimal_roots(dirs: &[PathBuf]) -> Vec<PathBuf> {
dirs.iter()
.filter(|dir| {
!dirs
.iter()
.any(|other| other != *dir && dir.starts_with(other))
})
.cloned()
.collect()
}
impl ProjectCache {
pub fn new(projects: Vec<PathBuf>, dir_mtimes: HashMap<PathBuf, u64>) -> Self {
Self {
projects,
last_updated: now_secs(),
dir_mtimes,
}
}
pub fn cache_path() -> Result<PathBuf> {
let cache_dir = dirs::cache_dir()
.context("Could not determine cache directory")?
.join("tmuxido");
fs::create_dir_all(&cache_dir).with_context(|| {
format!("Failed to create cache directory: {}", cache_dir.display())
})?;
Ok(cache_dir.join("projects.json"))
}
pub fn load() -> Result<Option<Self>> {
let cache_path = Self::cache_path()?;
if !cache_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&cache_path)
.with_context(|| format!("Failed to read cache file: {}", cache_path.display()))?;
let cache: ProjectCache = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse cache file: {}", cache_path.display()))?;
Ok(Some(cache))
}
pub fn save(&self) -> Result<()> {
let cache_path = Self::cache_path()?;
let content = serde_json::to_string_pretty(self).context("Failed to serialize cache")?;
fs::write(&cache_path, content)
.with_context(|| format!("Failed to write cache file: {}", cache_path.display()))?;
Ok(())
}
/// Valida e atualiza o cache de forma incremental.
///
/// 1. Remove projetos cujo `.git` não existe mais.
/// 2. Detecta diretórios com mtime alterado.
/// 3. Resscaneia apenas as subárvores mínimas que mudaram.
///
/// Retorna `true` se o cache foi modificado.
/// Retorna `false` com `dir_mtimes` vazio (cache antigo) — chamador deve fazer rescan completo.
#[allow(clippy::type_complexity)]
pub fn validate_and_update(
&mut self,
scan_fn: &dyn Fn(&Path) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>,
) -> Result<bool> {
let mut changed = false;
// Passo 1: remover projetos cujo .git não existe mais
let before = self.projects.len();
self.projects.retain(|p| p.join(".git").exists());
if self.projects.len() != before {
changed = true;
}
// Sem fingerprints = cache no formato antigo; sinaliza ao chamador
if self.dir_mtimes.is_empty() {
return Ok(changed);
}
// Passo 2: encontrar diretórios com mtime diferente do armazenado
let changed_dirs: Vec<PathBuf> = self
.dir_mtimes
.iter()
.filter(|(dir, stored_mtime)| {
fs::metadata(dir)
.and_then(|m| m.modified())
.map(|t| mtime_secs(t) != **stored_mtime)
.unwrap_or(true) // diretório sumiu = tratar como mudança
})
.map(|(dir, _)| dir.clone())
.collect();
if changed_dirs.is_empty() {
return Ok(changed);
}
// Passo 3: resscanear apenas as raízes mínimas das subárvores alteradas
for root in minimal_roots(&changed_dirs) {
eprintln!("Rescanning: {}", root.display());
// Remover entradas antigas desta subárvore
self.projects.retain(|p| !p.starts_with(&root));
self.dir_mtimes.retain(|d, _| !d.starts_with(&root));
// Resscanear e mesclar
let (new_projects, new_fingerprints) = scan_fn(&root)?;
self.projects.extend(new_projects);
self.dir_mtimes.extend(new_fingerprints);
changed = true;
}
if changed {
self.projects.sort();
self.projects.dedup();
self.last_updated = now_secs();
}
Ok(changed)
}
pub fn age_in_seconds(&self) -> u64 {
now_secs().saturating_sub(self.last_updated)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn should_return_empty_when_input_is_empty() {
let result = minimal_roots(&[]);
assert!(result.is_empty());
}
#[test]
fn should_return_single_dir_as_root() {
let dirs = vec![PathBuf::from("/home/user/projects")];
let result = minimal_roots(&dirs);
assert_eq!(result, dirs);
}
#[test]
fn should_exclude_nested_dirs_when_parent_is_present() {
let dirs = vec![
PathBuf::from("/home/user"),
PathBuf::from("/home/user/projects"),
];
let result = minimal_roots(&dirs);
assert_eq!(result.len(), 1);
assert!(result.contains(&PathBuf::from("/home/user")));
}
#[test]
fn should_keep_sibling_dirs_that_are_not_nested() {
let dirs = vec![
PathBuf::from("/home/user/projects"),
PathBuf::from("/home/user/work"),
];
let result = minimal_roots(&dirs);
assert_eq!(result.len(), 2);
}
#[test]
fn should_remove_stale_projects_when_git_dir_missing() {
let dir = tempdir().unwrap();
let project = dir.path().join("myproject");
fs::create_dir_all(project.join(".git")).unwrap();
let mut cache = ProjectCache::new(vec![project.clone()], HashMap::new());
assert_eq!(cache.projects.len(), 1);
fs::remove_dir_all(project.join(".git")).unwrap();
let result = cache.validate_and_update(&|_| Ok((vec![], HashMap::new())));
assert_eq!(result.unwrap(), true);
assert!(cache.projects.is_empty());
}
#[test]
fn should_return_false_when_nothing_changed() {
let dir = tempdir().unwrap();
let actual_mtime = fs::metadata(dir.path())
.unwrap()
.modified()
.unwrap()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let mut dir_mtimes = HashMap::new();
dir_mtimes.insert(dir.path().to_path_buf(), actual_mtime);
let mut cache = ProjectCache::new(vec![], dir_mtimes);
let result = cache.validate_and_update(&|_| Ok((vec![], HashMap::new())));
assert_eq!(result.unwrap(), false);
}
#[test]
fn should_rescan_dirs_when_mtime_changed() {
let dir = tempdir().unwrap();
let tracked = dir.path().to_path_buf();
// Store mtime 0 — guaranteed to differ from the actual mtime
let mut dir_mtimes = HashMap::new();
dir_mtimes.insert(tracked, 0u64);
let mut cache = ProjectCache::new(vec![], dir_mtimes);
let new_project = dir.path().join("discovered");
let scan_called = std::cell::Cell::new(false);
let result = cache.validate_and_update(&|_root| {
scan_called.set(true);
Ok((vec![new_project.clone()], HashMap::new()))
});
assert_eq!(result.unwrap(), true);
assert!(scan_called.get());
assert!(cache.projects.contains(&new_project));
}
#[test]
fn should_return_false_when_dir_mtimes_empty() {
let mut cache = ProjectCache::new(vec![], HashMap::new());
let result = cache.validate_and_update(&|_| Ok((vec![], HashMap::new())));
assert_eq!(result.unwrap(), false);
}
}

View File

@ -1,457 +0,0 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use crate::session::SessionConfig;
use crate::ui;
#[derive(Debug, Deserialize, Serialize)]
pub struct Config {
pub paths: Vec<String>,
#[serde(default = "default_max_depth")]
pub max_depth: usize,
#[serde(default = "default_cache_enabled")]
pub cache_enabled: bool,
#[serde(default = "default_cache_ttl_hours")]
pub cache_ttl_hours: u64,
#[serde(default = "default_update_check_interval_hours")]
pub update_check_interval_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_update_check_interval_hours() -> u64 {
24
}
fn default_session_config() -> SessionConfig {
use crate::session::Window;
SessionConfig {
windows: vec![
Window {
name: "editor".to_string(),
panes: vec![],
layout: None,
},
Window {
name: "terminal".to_string(),
panes: vec![],
layout: None,
},
],
}
}
impl Config {
pub fn load() -> Result<Self> {
let config_path = Self::config_path()?;
if !config_path.exists() {
return Ok(Self::default_config());
}
let content = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
let config: Config = toml::from_str(&content)
.with_context(|| format!("Failed to parse config file: {}", config_path.display()))?;
Ok(config)
}
pub fn config_path() -> Result<PathBuf> {
let config_dir = dirs::config_dir()
.context("Could not determine config directory")?
.join("tmuxido");
Ok(config_dir.join("tmuxido.toml"))
}
pub fn ensure_config_exists() -> Result<PathBuf> {
let config_path = Self::config_path()?;
if !config_path.exists() {
let config_dir = config_path
.parent()
.context("Could not get parent directory")?;
fs::create_dir_all(config_dir).with_context(|| {
format!(
"Failed to create config directory: {}",
config_dir.display()
)
})?;
// Ask whether to run the interactive wizard or apply sensible defaults
let raw = ui::render_setup_choice_prompt()?;
match ui::parse_setup_choice_input(&raw) {
ui::SetupChoice::Default => {
Self::write_default_config(&config_path)?;
ui::render_default_config_saved(&config_path.display().to_string());
}
ui::SetupChoice::Wizard => {
Self::run_wizard(&config_path)?;
}
}
// Offer shortcut and desktop integration regardless of setup mode
if let Err(e) = crate::shortcut::setup_shortcut_wizard() {
eprintln!("Warning: shortcut setup failed: {}", e);
}
if let Err(e) = crate::shortcut::setup_desktop_integration_wizard() {
eprintln!("Warning: desktop integration failed: {}", e);
}
}
Ok(config_path)
}
/// Write the built-in default config to `config_path` without any prompts.
fn write_default_config(config_path: &std::path::Path) -> Result<()> {
let config = Self::default_config();
let toml_string = toml::to_string_pretty(&config).context("Failed to serialize config")?;
fs::write(config_path, toml_string)
.with_context(|| format!("Failed to write config file: {}", config_path.display()))
}
/// Run the full interactive configuration wizard and offer shortcut / desktop setup at the end.
fn run_wizard(config_path: &std::path::Path) -> Result<()> {
let paths = Self::prompt_for_paths()?;
let max_depth = Self::prompt_for_max_depth()?;
let cache_enabled = Self::prompt_for_cache_enabled()?;
let cache_ttl_hours = if cache_enabled {
Self::prompt_for_cache_ttl()?
} else {
24
};
let windows = Self::prompt_for_windows()?;
// Render styled success message before moving windows
ui::render_config_created(&paths, max_depth, cache_enabled, cache_ttl_hours, &windows);
let config = Config {
paths,
max_depth,
cache_enabled,
cache_ttl_hours,
update_check_interval_hours: default_update_check_interval_hours(),
default_session: SessionConfig { windows },
};
let toml_string = toml::to_string_pretty(&config).context("Failed to serialize config")?;
fs::write(config_path, toml_string)
.with_context(|| format!("Failed to write config file: {}", config_path.display()))
}
fn prompt_for_paths() -> Result<Vec<String>> {
// Render styled welcome banner
ui::render_welcome_banner();
// Get input with styled prompt
let input = ui::render_paths_prompt()?;
let paths = Self::parse_paths_input(&input);
if paths.is_empty() {
ui::render_fallback_message();
Ok(vec![
dirs::home_dir()
.unwrap_or_default()
.join("Projects")
.to_string_lossy()
.to_string(),
])
} else {
Ok(paths)
}
}
fn prompt_for_max_depth() -> Result<usize> {
ui::render_section_header("Scan Settings");
let input = ui::render_max_depth_prompt()?;
Ok(ui::parse_max_depth_input(&input).unwrap_or(5))
}
fn prompt_for_cache_enabled() -> Result<bool> {
ui::render_section_header("Cache Settings");
let input = ui::render_cache_enabled_prompt()?;
Ok(ui::parse_cache_enabled_input(&input).unwrap_or(true))
}
fn prompt_for_cache_ttl() -> Result<u64> {
let input = ui::render_cache_ttl_prompt()?;
Ok(ui::parse_cache_ttl_input(&input).unwrap_or(24))
}
fn prompt_for_windows() -> Result<Vec<crate::session::Window>> {
ui::render_section_header("Default Session");
let input = ui::render_windows_prompt()?;
let window_names = ui::parse_comma_separated_list(&input);
let names = if window_names.is_empty() {
vec!["editor".to_string(), "terminal".to_string()]
} else {
window_names
};
// Configure panes and layout for each window
let mut windows = Vec::new();
for name in names {
let panes = Self::prompt_for_panes(&name)?;
let layout = if panes.len() > 1 {
ui::render_layout_prompt(&name, panes.len())?
} else {
None
};
windows.push(crate::session::Window {
name,
panes,
layout,
});
}
Ok(windows)
}
fn prompt_for_panes(window_name: &str) -> Result<Vec<String>> {
let input = ui::render_panes_prompt(window_name)?;
let pane_names = ui::parse_comma_separated_list(&input);
if pane_names.is_empty() {
// Single pane, no commands
return Ok(vec![]);
}
// Ask for commands for each pane
let mut panes = Vec::new();
for pane_name in pane_names {
let command = ui::render_pane_command_prompt(&pane_name)?;
panes.push(command);
}
Ok(panes)
}
fn parse_paths_input(input: &str) -> Vec<String> {
input
.trim()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
fn default_config() -> Self {
Config {
paths: vec![
dirs::home_dir()
.unwrap_or_default()
.join("Projects")
.to_string_lossy()
.to_string(),
],
max_depth: 5,
cache_enabled: true,
cache_ttl_hours: 24,
update_check_interval_hours: default_update_check_interval_hours(),
default_session: default_session_config(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_use_defaults_when_optional_fields_missing() {
let toml_str = r#"paths = ["/home/user/projects"]"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.max_depth, 5);
assert!(config.cache_enabled);
assert_eq!(config.cache_ttl_hours, 24);
assert_eq!(config.update_check_interval_hours, 24);
}
#[test]
fn should_parse_full_config_correctly() {
let toml_str = r#"
paths = ["/foo", "/bar"]
max_depth = 3
cache_enabled = false
cache_ttl_hours = 12
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.paths, vec!["/foo", "/bar"]);
assert_eq!(config.max_depth, 3);
assert!(!config.cache_enabled);
assert_eq!(config.cache_ttl_hours, 12);
}
#[test]
fn should_reject_invalid_toml() {
let result: Result<Config, _> = toml::from_str("not valid toml ]][[");
assert!(result.is_err());
}
#[test]
fn should_parse_single_path() {
let input = "~/Projects";
let paths = Config::parse_paths_input(input);
assert_eq!(paths, vec!["~/Projects"]);
}
#[test]
fn should_parse_multiple_paths_with_commas() {
let input = "~/Projects, ~/work, ~/repos";
let paths = Config::parse_paths_input(input);
assert_eq!(paths, vec!["~/Projects", "~/work", "~/repos"]);
}
#[test]
fn should_trim_whitespace_from_paths() {
let input = " ~/Projects , ~/work ";
let paths = Config::parse_paths_input(input);
assert_eq!(paths, vec!["~/Projects", "~/work"]);
}
#[test]
fn should_return_empty_vec_for_empty_input() {
let input = "";
let paths = Config::parse_paths_input(input);
assert!(paths.is_empty());
}
#[test]
fn should_return_empty_vec_for_whitespace_only() {
let input = " ";
let paths = Config::parse_paths_input(input);
assert!(paths.is_empty());
}
#[test]
fn should_handle_empty_parts_between_commas() {
let input = "~/Projects,,~/work";
let paths = Config::parse_paths_input(input);
assert_eq!(paths, vec!["~/Projects", "~/work"]);
}
#[test]
fn should_use_ui_parse_functions_for_max_depth() {
// Test that our UI parsing produces expected results
assert_eq!(ui::parse_max_depth_input(""), None);
assert_eq!(ui::parse_max_depth_input("5"), Some(5));
assert_eq!(ui::parse_max_depth_input("invalid"), None);
}
#[test]
fn should_use_ui_parse_functions_for_cache_enabled() {
assert_eq!(ui::parse_cache_enabled_input(""), None);
assert_eq!(ui::parse_cache_enabled_input("y"), Some(true));
assert_eq!(ui::parse_cache_enabled_input("n"), Some(false));
assert_eq!(ui::parse_cache_enabled_input("maybe"), None);
}
#[test]
fn should_use_ui_parse_functions_for_cache_ttl() {
assert_eq!(ui::parse_cache_ttl_input(""), None);
assert_eq!(ui::parse_cache_ttl_input("24"), Some(24));
assert_eq!(ui::parse_cache_ttl_input("invalid"), None);
}
#[test]
fn should_use_ui_parse_functions_for_window_names() {
let result = ui::parse_comma_separated_list("editor, terminal, server");
assert_eq!(result, vec!["editor", "terminal", "server"]);
}
#[test]
fn should_use_ui_parse_functions_for_layout() {
assert_eq!(ui::parse_layout_input(""), None);
assert_eq!(
ui::parse_layout_input("1"),
Some("main-horizontal".to_string())
);
assert_eq!(
ui::parse_layout_input("main-vertical"),
Some("main-vertical".to_string())
);
assert_eq!(ui::parse_layout_input("invalid"), None);
}
#[test]
fn should_write_default_config_to_file() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("tmuxido.toml");
Config::write_default_config(&config_path).unwrap();
assert!(config_path.exists());
let content = std::fs::read_to_string(&config_path).unwrap();
let loaded: Config = toml::from_str(&content).unwrap();
assert!(!loaded.paths.is_empty());
assert_eq!(loaded.max_depth, 5);
assert!(loaded.cache_enabled);
assert_eq!(loaded.cache_ttl_hours, 24);
}
#[test]
fn should_write_valid_toml_in_default_config() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("tmuxido.toml");
Config::write_default_config(&config_path).unwrap();
let content = std::fs::read_to_string(&config_path).unwrap();
// Must parse cleanly
let result: Result<Config, _> = toml::from_str(&content);
assert!(result.is_ok(), "Default config must be valid TOML");
}
#[test]
fn should_parse_config_with_windows_and_panes() {
let toml_str = r#"
paths = ["/projects"]
max_depth = 3
cache_enabled = true
cache_ttl_hours = 12
[default_session]
[[default_session.windows]]
name = "editor"
panes = ["nvim .", "git status"]
[[default_session.windows]]
name = "terminal"
panes = []
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.paths, vec!["/projects"]);
assert_eq!(config.max_depth, 3);
assert!(config.cache_enabled);
assert_eq!(config.cache_ttl_hours, 12);
assert_eq!(config.default_session.windows.len(), 2);
assert_eq!(config.default_session.windows[0].name, "editor");
assert_eq!(config.default_session.windows[0].panes.len(), 2);
assert_eq!(config.default_session.windows[0].panes[0], "nvim .");
assert_eq!(config.default_session.windows[0].panes[1], "git status");
assert_eq!(config.default_session.windows[1].name, "terminal");
assert!(config.default_session.windows[1].panes.is_empty());
}
}

View File

@ -1,425 +0,0 @@
use anyhow::{Context, Result};
use std::io::{self, Write};
use std::process::{Command, Stdio};
/// Required external tool dependencies.
#[derive(Debug, Clone, PartialEq)]
pub enum Dep {
Fzf,
Tmux,
}
/// Supported Linux package managers.
#[derive(Debug, Clone, PartialEq)]
pub enum PackageManager {
Apt,
Pacman,
Dnf,
Yum,
Zypper,
Emerge,
Xbps,
Apk,
}
/// Injectable binary availability checker — enables unit testing without hitting the real system.
pub trait BinaryChecker {
fn is_available(&self, name: &str) -> bool;
}
/// Production implementation: delegates to the system `which` command.
pub struct SystemBinaryChecker;
impl BinaryChecker for SystemBinaryChecker {
fn is_available(&self, name: &str) -> bool {
Command::new("which")
.arg(name)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
}
impl Dep {
pub fn all() -> Vec<Self> {
vec![Self::Fzf, Self::Tmux]
}
pub fn binary_name(&self) -> &str {
match self {
Self::Fzf => "fzf",
Self::Tmux => "tmux",
}
}
pub fn package_name(&self) -> &str {
match self {
Self::Fzf => "fzf",
Self::Tmux => "tmux",
}
}
}
impl PackageManager {
/// Ordered list for detection — more specific managers first.
pub fn all_ordered() -> Vec<Self> {
vec![
Self::Apt,
Self::Pacman,
Self::Dnf,
Self::Yum,
Self::Zypper,
Self::Emerge,
Self::Xbps,
Self::Apk,
]
}
/// Binary used to detect whether this package manager is installed.
pub fn detection_binary(&self) -> &str {
match self {
Self::Apt => "apt",
Self::Pacman => "pacman",
Self::Dnf => "dnf",
Self::Yum => "yum",
Self::Zypper => "zypper",
Self::Emerge => "emerge",
Self::Xbps => "xbps-install",
Self::Apk => "apk",
}
}
pub fn display_name(&self) -> &str {
match self {
Self::Apt => "apt (Debian/Ubuntu)",
Self::Pacman => "pacman (Arch Linux)",
Self::Dnf => "dnf (Fedora)",
Self::Yum => "yum (RHEL/CentOS)",
Self::Zypper => "zypper (openSUSE)",
Self::Emerge => "emerge (Gentoo)",
Self::Xbps => "xbps-install (Void Linux)",
Self::Apk => "apk (Alpine Linux)",
}
}
/// Builds the full install command (including `sudo`) for the given packages.
pub fn install_command(&self, packages: &[&str]) -> Vec<String> {
let mut cmd = vec!["sudo".to_string()];
match self {
Self::Apt => cmd.extend(["apt", "install", "-y"].map(String::from)),
Self::Pacman => cmd.extend(["pacman", "-S", "--noconfirm"].map(String::from)),
Self::Dnf => cmd.extend(["dnf", "install", "-y"].map(String::from)),
Self::Yum => cmd.extend(["yum", "install", "-y"].map(String::from)),
Self::Zypper => cmd.extend(["zypper", "install", "-y"].map(String::from)),
Self::Emerge => cmd.extend(["emerge"].map(String::from)),
Self::Xbps => cmd.extend(["xbps-install", "-y"].map(String::from)),
Self::Apk => cmd.extend(["apk", "add"].map(String::from)),
}
cmd.extend(packages.iter().map(|&s| s.to_string()));
cmd
}
}
/// Returns the required deps that are not currently installed.
pub fn check_missing<C: BinaryChecker>(checker: &C) -> Vec<Dep> {
Dep::all()
.into_iter()
.filter(|dep| !checker.is_available(dep.binary_name()))
.collect()
}
/// Returns the first supported package manager found on the system.
pub fn detect_package_manager<C: BinaryChecker>(checker: &C) -> Option<PackageManager> {
PackageManager::all_ordered()
.into_iter()
.find(|pm| checker.is_available(pm.detection_binary()))
}
/// Checks for missing dependencies, informs the user, and offers to install them.
///
/// Returns `Ok(())` if all deps are available (or successfully installed).
pub fn ensure_dependencies() -> Result<()> {
let checker = SystemBinaryChecker;
let missing = check_missing(&checker);
if missing.is_empty() {
return Ok(());
}
eprintln!("The following required tools are not installed:");
for dep in &missing {
eprintln!("{}", dep.binary_name());
}
eprintln!();
let pm = detect_package_manager(&checker).ok_or_else(|| {
anyhow::anyhow!(
"No supported package manager found. Please install {} manually.",
missing
.iter()
.map(|d| d.binary_name())
.collect::<Vec<_>>()
.join(" and ")
)
})?;
let packages: Vec<&str> = missing.iter().map(|d| d.package_name()).collect();
let cmd = pm.install_command(&packages);
eprintln!("Detected package manager: {}", pm.display_name());
eprintln!("Install command: {}", cmd.join(" "));
eprint!("\nProceed with installation? [Y/n] ");
io::stdout().flush().ok();
let mut answer = String::new();
io::stdin()
.read_line(&mut answer)
.context("Failed to read user input")?;
let answer = answer.trim().to_lowercase();
if answer == "n" || answer == "no" {
anyhow::bail!(
"Installation cancelled. Please install {} manually before running tmuxido.",
missing
.iter()
.map(|d| d.binary_name())
.collect::<Vec<_>>()
.join(" and ")
);
}
let (program, args) = cmd
.split_first()
.expect("install_command always returns at least one element");
let status = Command::new(program)
.args(args)
.status()
.with_context(|| format!("Failed to run: {}", cmd.join(" ")))?;
if !status.success() {
anyhow::bail!(
"Installation failed. Please install {} manually.",
missing
.iter()
.map(|d| d.binary_name())
.collect::<Vec<_>>()
.join(" and ")
);
}
eprintln!("Installation complete!");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
struct MockChecker {
available: Vec<String>,
}
impl MockChecker {
fn with(available: &[&str]) -> Self {
Self {
available: available.iter().map(|s| s.to_string()).collect(),
}
}
}
impl BinaryChecker for MockChecker {
fn is_available(&self, name: &str) -> bool {
self.available.iter().any(|s| s == name)
}
}
// --- Dep ---
#[test]
fn should_return_fzf_binary_name() {
assert_eq!(Dep::Fzf.binary_name(), "fzf");
}
#[test]
fn should_return_tmux_binary_name() {
assert_eq!(Dep::Tmux.binary_name(), "tmux");
}
#[test]
fn should_include_fzf_and_tmux_in_all_deps() {
let deps = Dep::all();
assert!(deps.contains(&Dep::Fzf));
assert!(deps.contains(&Dep::Tmux));
}
#[test]
fn should_return_same_package_name_as_binary_for_fzf() {
assert_eq!(Dep::Fzf.package_name(), "fzf");
}
#[test]
fn should_return_same_package_name_as_binary_for_tmux() {
assert_eq!(Dep::Tmux.package_name(), "tmux");
}
// --- check_missing ---
#[test]
fn should_return_empty_when_all_deps_present() {
let checker = MockChecker::with(&["fzf", "tmux"]);
assert!(check_missing(&checker).is_empty());
}
#[test]
fn should_detect_fzf_as_missing_when_only_tmux_present() {
let checker = MockChecker::with(&["tmux"]);
let missing = check_missing(&checker);
assert_eq!(missing, vec![Dep::Fzf]);
}
#[test]
fn should_detect_tmux_as_missing_when_only_fzf_present() {
let checker = MockChecker::with(&["fzf"]);
let missing = check_missing(&checker);
assert_eq!(missing, vec![Dep::Tmux]);
}
#[test]
fn should_detect_both_missing_when_none_present() {
let checker = MockChecker::with(&[]);
let missing = check_missing(&checker);
assert_eq!(missing.len(), 2);
assert!(missing.contains(&Dep::Fzf));
assert!(missing.contains(&Dep::Tmux));
}
// --- detect_package_manager ---
#[test]
fn should_detect_apt_when_available() {
let checker = MockChecker::with(&["apt"]);
assert_eq!(detect_package_manager(&checker), Some(PackageManager::Apt));
}
#[test]
fn should_detect_pacman_when_available() {
let checker = MockChecker::with(&["pacman"]);
assert_eq!(
detect_package_manager(&checker),
Some(PackageManager::Pacman)
);
}
#[test]
fn should_detect_dnf_when_available() {
let checker = MockChecker::with(&["dnf"]);
assert_eq!(detect_package_manager(&checker), Some(PackageManager::Dnf));
}
#[test]
fn should_detect_xbps_when_xbps_install_available() {
let checker = MockChecker::with(&["xbps-install"]);
assert_eq!(detect_package_manager(&checker), Some(PackageManager::Xbps));
}
#[test]
fn should_detect_apk_when_available() {
let checker = MockChecker::with(&["apk"]);
assert_eq!(detect_package_manager(&checker), Some(PackageManager::Apk));
}
#[test]
fn should_return_none_when_no_pm_detected() {
let checker = MockChecker::with(&["ls", "sh"]);
assert_eq!(detect_package_manager(&checker), None);
}
#[test]
fn should_prefer_apt_over_pacman_when_both_available() {
let checker = MockChecker::with(&["apt", "pacman"]);
assert_eq!(detect_package_manager(&checker), Some(PackageManager::Apt));
}
// --- PackageManager::install_command ---
#[test]
fn should_build_apt_install_command() {
let cmd = PackageManager::Apt.install_command(&["fzf", "tmux"]);
assert_eq!(cmd, vec!["sudo", "apt", "install", "-y", "fzf", "tmux"]);
}
#[test]
fn should_build_pacman_install_command() {
let cmd = PackageManager::Pacman.install_command(&["fzf", "tmux"]);
assert_eq!(
cmd,
vec!["sudo", "pacman", "-S", "--noconfirm", "fzf", "tmux"]
);
}
#[test]
fn should_build_dnf_install_command() {
let cmd = PackageManager::Dnf.install_command(&["fzf"]);
assert_eq!(cmd, vec!["sudo", "dnf", "install", "-y", "fzf"]);
}
#[test]
fn should_build_yum_install_command() {
let cmd = PackageManager::Yum.install_command(&["tmux"]);
assert_eq!(cmd, vec!["sudo", "yum", "install", "-y", "tmux"]);
}
#[test]
fn should_build_zypper_install_command() {
let cmd = PackageManager::Zypper.install_command(&["fzf", "tmux"]);
assert_eq!(cmd, vec!["sudo", "zypper", "install", "-y", "fzf", "tmux"]);
}
#[test]
fn should_build_emerge_install_command() {
let cmd = PackageManager::Emerge.install_command(&["fzf"]);
assert_eq!(cmd, vec!["sudo", "emerge", "fzf"]);
}
#[test]
fn should_build_xbps_install_command() {
let cmd = PackageManager::Xbps.install_command(&["tmux"]);
assert_eq!(cmd, vec!["sudo", "xbps-install", "-y", "tmux"]);
}
#[test]
fn should_build_apk_install_command() {
let cmd = PackageManager::Apk.install_command(&["fzf", "tmux"]);
assert_eq!(cmd, vec!["sudo", "apk", "add", "fzf", "tmux"]);
}
#[test]
fn should_build_command_for_single_package() {
let cmd = PackageManager::Apt.install_command(&["fzf"]);
assert_eq!(cmd, vec!["sudo", "apt", "install", "-y", "fzf"]);
}
#[test]
fn should_include_sudo_for_all_package_managers() {
for pm in PackageManager::all_ordered() {
let cmd = pm.install_command(&["fzf"]);
assert_eq!(
cmd.first().map(String::as_str),
Some("sudo"),
"{} install command should start with sudo",
pm.display_name()
);
}
}
#[test]
fn should_include_all_packages_in_command() {
let cmd = PackageManager::Apt.install_command(&["fzf", "tmux", "git"]);
assert!(cmd.contains(&"fzf".to_string()));
assert!(cmd.contains(&"tmux".to_string()));
assert!(cmd.contains(&"git".to_string()));
}
}

View File

@ -1,427 +0,0 @@
pub mod cache;
pub mod config;
pub mod deps;
pub mod self_update;
pub mod session;
pub mod shortcut;
pub mod ui;
pub mod update_check;
use anyhow::Result;
use cache::ProjectCache;
use config::Config;
use session::{SessionConfig, TmuxSession};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use walkdir::WalkDir;
pub fn setup_shortcut_wizard() -> Result<()> {
shortcut::setup_shortcut_wizard()
}
pub fn setup_desktop_integration_wizard() -> Result<()> {
shortcut::setup_desktop_integration_wizard()
}
pub 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(())
}
pub fn get_projects(config: &Config, force_refresh: bool) -> Result<Vec<PathBuf>> {
get_projects_internal(
config,
force_refresh,
&ProjectCache::load,
&|cache| cache.save(),
&scan_all_roots,
&spawn_background_refresh,
)
}
/// Rebuilds the project cache incrementally. Intended to be called from a
/// background process spawned by `get_projects` via stale-while-revalidate.
pub fn refresh_cache(config: &Config) -> Result<()> {
match ProjectCache::load()? {
None => {
let (projects, fingerprints) = scan_all_roots(config)?;
ProjectCache::new(projects, fingerprints).save()?;
}
Some(mut cache) => {
if cache.dir_mtimes.is_empty() {
// Old cache format — full rescan
let (projects, fingerprints) = scan_all_roots(config)?;
ProjectCache::new(projects, fingerprints).save()?;
} else {
// Incremental rescan based on directory mtimes
let changed = cache.validate_and_update(&|root| scan_from_root(root, config))?;
if changed {
cache.save()?;
}
}
}
}
Ok(())
}
fn spawn_background_refresh() {
if let Ok(exe) = std::env::current_exe() {
std::process::Command::new(exe)
.arg("--background-refresh")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.ok();
}
}
#[allow(clippy::type_complexity)]
fn get_projects_internal(
config: &Config,
force_refresh: bool,
cache_loader: &dyn Fn() -> Result<Option<ProjectCache>>,
cache_saver: &dyn Fn(&ProjectCache) -> Result<()>,
scanner: &dyn Fn(&Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>,
refresh_spawner: &dyn Fn(),
) -> Result<Vec<PathBuf>> {
if !config.cache_enabled || force_refresh {
let (projects, fingerprints) = scanner(config)?;
let cache = ProjectCache::new(projects.clone(), fingerprints);
cache_saver(&cache)?;
return Ok(projects);
}
if let Some(cache) = cache_loader()? {
// Cache exists — return immediately (stale-while-revalidate).
// Spawn a background refresh if the cache is stale or in old format.
let is_stale =
cache.dir_mtimes.is_empty() || cache.age_in_seconds() > config.cache_ttl_hours * 3600;
if is_stale {
refresh_spawner();
}
return Ok(cache.projects);
}
// No cache yet — first run, blocking scan is unavoidable.
eprintln!("No cache found, scanning for projects...");
let (projects, fingerprints) = scanner(config)?;
let cache = ProjectCache::new(projects.clone(), fingerprints);
cache_saver(&cache)?;
Ok(projects)
}
pub fn scan_all_roots(config: &Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)> {
let mut all_projects = Vec::new();
let mut all_fingerprints = HashMap::new();
for path_str in &config.paths {
let path = PathBuf::from(shellexpand::tilde(path_str).to_string());
if !path.exists() {
eprintln!("Warning: Path does not exist: {}", path.display());
continue;
}
eprintln!("Scanning: {}", path.display());
let (projects, fingerprints) = scan_from_root(&path, config)?;
all_projects.extend(projects);
all_fingerprints.extend(fingerprints);
}
all_projects.sort();
all_projects.dedup();
Ok((all_projects, all_fingerprints))
}
pub fn scan_from_root(
root: &Path,
config: &Config,
) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)> {
let mut projects = Vec::new();
let mut fingerprints = HashMap::new();
for entry in WalkDir::new(root)
.max_depth(config.max_depth)
.follow_links(false)
.into_iter()
.filter_entry(|e| {
e.file_name()
.to_str()
.map(|s| !s.starts_with('.') || s == ".git")
.unwrap_or(false)
})
{
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
if entry.file_type().is_dir() {
if entry.file_name() == ".git" {
// Projeto encontrado
if let Some(parent) = entry.path().parent() {
projects.push(parent.to_path_buf());
}
} else {
// Registrar mtime para detecção de mudanças futuras
if let Ok(metadata) = entry.metadata()
&& 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))
}
pub 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(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
fn make_config(cache_enabled: bool, cache_ttl_hours: u64) -> Config {
Config {
paths: vec!["/tmp/test".to_string()],
max_depth: 3,
cache_enabled,
cache_ttl_hours,
update_check_interval_hours: 24,
default_session: session::SessionConfig { windows: vec![] },
}
}
fn fresh_cache(projects: Vec<PathBuf>) -> ProjectCache {
let fingerprints = HashMap::from([(PathBuf::from("/tracked"), 1u64)]);
ProjectCache::new(projects, fingerprints)
// last_updated = now_secs() — within any reasonable TTL
}
fn stale_cache(projects: Vec<PathBuf>) -> ProjectCache {
let fingerprints = HashMap::from([(PathBuf::from("/tracked"), 1u64)]);
let mut c = ProjectCache::new(projects, fingerprints);
c.last_updated = 0; // epoch — always older than TTL
c
}
fn call_internal(
config: &Config,
force_refresh: bool,
cache_loader: &dyn Fn() -> Result<Option<ProjectCache>>,
cache_saver: &dyn Fn(&ProjectCache) -> Result<()>,
scanner: &dyn Fn(&Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>,
refresh_spawner: &dyn Fn(),
) -> Result<Vec<PathBuf>> {
get_projects_internal(
config,
force_refresh,
cache_loader,
cache_saver,
scanner,
refresh_spawner,
)
}
#[test]
fn should_scan_when_cache_disabled() {
let config = make_config(false, 24);
let expected = vec![PathBuf::from("/p1")];
let scanner_called = RefCell::new(false);
let saver_called = RefCell::new(false);
let spawner_called = RefCell::new(false);
let result = call_internal(
&config,
false,
&|| panic!("loader must not be called when cache disabled"),
&|_| {
*saver_called.borrow_mut() = true;
Ok(())
},
&|_| {
*scanner_called.borrow_mut() = true;
Ok((expected.clone(), HashMap::new()))
},
&|| *spawner_called.borrow_mut() = true,
);
assert!(result.is_ok());
assert!(scanner_called.into_inner());
assert!(saver_called.into_inner());
assert!(!spawner_called.into_inner());
assert_eq!(result.unwrap(), expected);
}
#[test]
fn should_scan_when_force_refresh() {
let config = make_config(true, 24);
let expected = vec![PathBuf::from("/p1")];
let scanner_called = RefCell::new(false);
let saver_called = RefCell::new(false);
let spawner_called = RefCell::new(false);
let result = call_internal(
&config,
true,
&|| panic!("loader must not be called on force refresh"),
&|_| {
*saver_called.borrow_mut() = true;
Ok(())
},
&|_| {
*scanner_called.borrow_mut() = true;
Ok((expected.clone(), HashMap::new()))
},
&|| *spawner_called.borrow_mut() = true,
);
assert!(result.is_ok());
assert!(scanner_called.into_inner());
assert!(saver_called.into_inner());
assert!(!spawner_called.into_inner());
assert_eq!(result.unwrap(), expected);
}
#[test]
fn should_do_blocking_scan_when_no_cache_exists() {
let config = make_config(true, 24);
let expected = vec![PathBuf::from("/p1")];
let scanner_called = RefCell::new(false);
let saver_called = RefCell::new(false);
let spawner_called = RefCell::new(false);
let result = call_internal(
&config,
false,
&|| Ok(None),
&|_| {
*saver_called.borrow_mut() = true;
Ok(())
},
&|_| {
*scanner_called.borrow_mut() = true;
Ok((expected.clone(), HashMap::new()))
},
&|| *spawner_called.borrow_mut() = true,
);
assert!(result.is_ok());
assert!(scanner_called.into_inner());
assert!(saver_called.into_inner());
assert!(!spawner_called.into_inner());
assert_eq!(result.unwrap(), expected);
}
#[test]
fn should_return_cached_projects_immediately_when_cache_is_fresh() {
let config = make_config(true, 24);
let cached = vec![PathBuf::from("/cached/project")];
let cache = RefCell::new(Some(fresh_cache(cached.clone())));
let spawner_called = RefCell::new(false);
let result = call_internal(
&config,
false,
&|| Ok(cache.borrow_mut().take()),
&|_| panic!("saver must not be called in foreground"),
&|_| panic!("scanner must not be called when cache is fresh"),
&|| *spawner_called.borrow_mut() = true,
);
assert!(result.is_ok());
assert_eq!(result.unwrap(), cached);
assert!(
!spawner_called.into_inner(),
"fresh cache should not trigger background refresh"
);
}
#[test]
fn should_return_stale_cache_immediately_and_spawn_background_refresh() {
let config = make_config(true, 24);
let cached = vec![PathBuf::from("/cached/project")];
let cache = RefCell::new(Some(stale_cache(cached.clone())));
let spawner_called = RefCell::new(false);
let result = call_internal(
&config,
false,
&|| Ok(cache.borrow_mut().take()),
&|_| panic!("saver must not be called in foreground"),
&|_| panic!("scanner must not be called in foreground"),
&|| *spawner_called.borrow_mut() = true,
);
assert!(result.is_ok());
assert_eq!(result.unwrap(), cached);
assert!(
spawner_called.into_inner(),
"stale cache must trigger background refresh"
);
}
#[test]
fn should_spawn_background_refresh_when_cache_has_no_fingerprints() {
let config = make_config(true, 24);
let cached = vec![PathBuf::from("/old/project")];
// Old cache format: no dir_mtimes
let old_cache = RefCell::new(Some(ProjectCache::new(cached.clone(), HashMap::new())));
let spawner_called = RefCell::new(false);
let result = call_internal(
&config,
false,
&|| Ok(old_cache.borrow_mut().take()),
&|_| panic!("saver must not be called in foreground"),
&|_| panic!("scanner must not be called in foreground"),
&|| *spawner_called.borrow_mut() = true,
);
assert!(result.is_ok());
assert_eq!(result.unwrap(), cached);
assert!(
spawner_called.into_inner(),
"old cache format must trigger background refresh"
);
}
}

View File

@ -1,172 +0,0 @@
use anyhow::{Context, Result};
use clap::Parser;
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use tmuxido::config::Config;
use tmuxido::deps::ensure_dependencies;
use tmuxido::self_update;
use tmuxido::update_check;
use tmuxido::{
get_projects, launch_tmux_session, refresh_cache, setup_desktop_integration_wizard,
setup_shortcut_wizard, show_cache_status,
};
#[derive(Parser, Debug)]
#[command(
name = "tmuxido",
about = "Quickly find and open projects in tmux",
version
)]
struct Args {
/// Project path to open directly (skips selection)
project_path: Option<PathBuf>,
/// Force refresh the project cache
#[arg(short, long)]
refresh: bool,
/// Show cache status and exit
#[arg(long)]
cache_status: bool,
/// Update tmuxido to the latest version
#[arg(long)]
update: bool,
/// Set up a keyboard shortcut to launch tmuxido
#[arg(long)]
setup_shortcut: bool,
/// Install the .desktop entry and icon for app launcher integration
#[arg(long)]
setup_desktop_shortcut: bool,
/// Internal: rebuild cache in background (used by stale-while-revalidate)
#[arg(long, hide = true)]
background_refresh: bool,
}
fn main() -> Result<()> {
let args = Args::parse();
// Handle self-update before anything else
if args.update {
return self_update::self_update();
}
// Handle background cache refresh (spawned internally by stale-while-revalidate).
// Runs early to avoid unnecessary dependency checks and config prompts.
if args.background_refresh {
let config = Config::load()?;
return refresh_cache(&config);
}
// Handle standalone shortcut setup
if args.setup_shortcut {
return setup_shortcut_wizard();
}
// Handle standalone desktop integration setup
if args.setup_desktop_shortcut {
return setup_desktop_integration_wizard();
}
// Check that fzf and tmux are installed; offer to install if missing
ensure_dependencies()?;
// Ensure config exists
Config::ensure_config_exists()?;
// Load config
let config = Config::load()?;
// Periodic update check (silent on failure or no update)
update_check::check_and_notify(&config);
// 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 readme_preview_command() -> String {
let glow_available = Command::new("sh")
.args(["-c", "command -v glow"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
// CLICOLOR_FORCE=1 tells termenv (used by glow/glamour) to enable ANSI
// colors even when stdout is not a TTY (fzf preview runs in a pipe).
// Without it, glow falls back to bold-only "notty" style with no colors.
// Use `sh -c '...' -- {}` so the command runs in POSIX sh regardless of
// the user's $SHELL (fish, zsh, bash, etc.).
let viewer_cmd = if glow_available {
"CLICOLOR_FORCE=1 glow -s dark"
} else {
"cat"
};
format!(
r#"sh -c 'readme="$1/README.md"; [ -f "$readme" ] && {viewer_cmd} "$readme" || echo "No README.md"' -- {{}}"#
)
}
fn select_project_with_fzf(projects: &[PathBuf]) -> Result<PathBuf> {
let preview_cmd = readme_preview_command();
let mut child = Command::new("fzf")
.arg("--preview")
.arg(&preview_cmd)
.arg("--preview-window")
.arg("right:40%")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.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))
}

View File

@ -1,254 +0,0 @@
use anyhow::{Context, Result};
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::process::Command;
const REPO: &str = "cinco/tmuxido";
const BASE_URL: &str = "https://github.com";
const API_BASE: &str = "https://api.github.com";
/// Check if running from cargo (development mode)
fn is_dev_build() -> bool {
option_env!("CARGO_PKG_NAME").is_none()
}
/// Get current version from cargo
pub fn current_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
/// Detect system architecture
fn detect_arch() -> Result<&'static str> {
let arch = std::env::consts::ARCH;
match arch {
"x86_64" => Ok("tmuxido-x86_64-linux"),
"aarch64" => Ok("tmuxido-aarch64-linux"),
_ => Err(anyhow::anyhow!("Unsupported architecture: {}", arch)),
}
}
/// Parse tag_name from a GitHub releases/latest JSON response
fn parse_latest_tag(response: &str) -> Result<String> {
let tag: serde_json::Value =
serde_json::from_str(response).context("Failed to parse release API response")?;
tag.get("tag_name")
.and_then(|t| t.as_str())
.map(|t| t.to_string())
.ok_or_else(|| anyhow::anyhow!("Could not extract tag_name from release"))
}
/// Fetch latest release tag from GitHub API
pub(crate) fn fetch_latest_tag() -> Result<String> {
let url = format!("{}/repos/{}/releases/latest", API_BASE, REPO);
let output = Command::new("curl")
.args([
"-fsSL",
"-H",
"Accept: application/vnd.github.v3+json",
&url,
])
.output()
.context("Failed to execute curl. Make sure curl is installed.")?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"Failed to fetch latest release: {}",
String::from_utf8_lossy(&output.stderr)
));
}
parse_latest_tag(&String::from_utf8_lossy(&output.stdout))
}
/// Get path to current executable
fn get_current_exe() -> Result<PathBuf> {
std::env::current_exe().context("Failed to get current executable path")
}
/// Download binary to a temporary location
fn download_binary(tag: &str, arch: &str, temp_path: &std::path::Path) -> Result<()> {
let url = format!("{}/{}/releases/download/{}/{}", BASE_URL, REPO, tag, arch);
println!("Downloading {}...", url);
let output = Command::new("curl")
.args(["-fsSL", &url, "-o", &temp_path.to_string_lossy()])
.output()
.context("Failed to execute curl for download")?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"Failed to download binary: {}",
String::from_utf8_lossy(&output.stderr)
));
}
// Make executable
let mut perms = std::fs::metadata(temp_path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(temp_path, perms)?;
Ok(())
}
/// Perform self-update
pub fn self_update() -> Result<()> {
if is_dev_build() {
println!("Development build detected. Skipping self-update.");
return Ok(());
}
let current = current_version();
println!("Current version: {}", current);
let latest = fetch_latest_tag()?;
let latest_clean = latest.trim_start_matches('v');
println!("Latest version: {}", latest);
// Compare versions (simple string comparison for semver without 'v' prefix)
if latest_clean == current {
println!("Already up to date!");
return Ok(());
}
// Check if latest is actually newer
match version_compare(latest_clean, current) {
std::cmp::Ordering::Less => {
println!("Current version is newer than release. Skipping update.");
return Ok(());
}
std::cmp::Ordering::Equal => {
println!("Already up to date!");
return Ok(());
}
_ => {}
}
let arch = detect_arch()?;
let exe_path = get_current_exe()?;
// Create temporary file in same directory as target (for atomic rename)
let exe_dir = exe_path
.parent()
.ok_or_else(|| anyhow::anyhow!("Could not determine executable directory"))?;
let temp_path = exe_dir.join(".tmuxido.new");
println!("Downloading update...");
download_binary(&latest, arch, &temp_path)?;
// Verify the downloaded binary works
let verify = Command::new(&temp_path).arg("--version").output();
if let Err(e) = verify {
let _ = std::fs::remove_file(&temp_path);
return Err(anyhow::anyhow!(
"Downloaded binary verification failed: {}",
e
));
}
// Atomic replace: rename old to .old, rename new to target
let backup_path = exe_path.with_extension("old");
// Remove old backup if exists
let _ = std::fs::remove_file(&backup_path);
// Rename current to backup
std::fs::rename(&exe_path, &backup_path)
.context("Failed to backup current binary (is tmuxido running?)")?;
// Move new to current location
if let Err(e) = std::fs::rename(&temp_path, &exe_path) {
// Restore backup on failure
let _ = std::fs::rename(&backup_path, &exe_path);
return Err(anyhow::anyhow!("Failed to install new binary: {}", e));
}
// Remove backup on success
let _ = std::fs::remove_file(&backup_path);
println!("Successfully updated to {}!", latest);
Ok(())
}
/// Compare two semver versions
pub(crate) fn version_compare(a: &str, b: &str) -> std::cmp::Ordering {
let parse = |s: &str| {
s.split('.')
.filter_map(|n| n.parse::<u32>().ok())
.collect::<Vec<_>>()
};
let a_parts = parse(a);
let b_parts = parse(b);
for (a_part, b_part) in a_parts.iter().zip(b_parts.iter()) {
match a_part.cmp(b_part) {
std::cmp::Ordering::Equal => continue,
other => return other,
}
}
a_parts.len().cmp(&b_parts.len())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_detect_current_version() {
let version = current_version();
// Version should be non-empty and contain dots
assert!(!version.is_empty());
assert!(version.contains('.'));
}
#[test]
fn should_prefix_arch_asset_with_tmuxido() {
let arch = detect_arch().expect("should detect supported arch");
assert!(
arch.starts_with("tmuxido-"),
"asset name must start with 'tmuxido-', got: {arch}"
);
assert!(
arch.ends_with("-linux"),
"asset name must end with '-linux', got: {arch}"
);
}
#[test]
fn should_parse_tag_from_github_latest_release_response() {
let json = r#"{"tag_name":"0.7.0","name":"0.7.0","body":"release notes"}"#;
assert_eq!(parse_latest_tag(json).unwrap(), "0.7.0");
}
#[test]
fn should_return_error_when_tag_name_missing() {
let json = r#"{"name":"0.7.0","body":"no tag_name field"}"#;
assert!(parse_latest_tag(json).is_err());
}
#[test]
fn should_return_error_when_response_is_invalid_json() {
assert!(parse_latest_tag("not valid json").is_err());
}
#[test]
fn should_compare_versions_correctly() {
assert_eq!(
version_compare("0.3.0", "0.2.4"),
std::cmp::Ordering::Greater
);
assert_eq!(version_compare("0.2.4", "0.3.0"), std::cmp::Ordering::Less);
assert_eq!(version_compare("0.3.0", "0.3.0"), std::cmp::Ordering::Equal);
assert_eq!(
version_compare("1.0.0", "0.9.9"),
std::cmp::Ordering::Greater
);
assert_eq!(
version_compare("0.10.0", "0.9.0"),
std::cmp::Ordering::Greater
);
}
}

View File

@ -1,277 +0,0 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use std::process::Command;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Window {
pub name: String,
#[serde(default)]
pub panes: Vec<String>,
#[serde(default)]
pub layout: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SessionConfig {
#[serde(default)]
pub windows: Vec<Window>,
}
impl SessionConfig {
pub fn load_from_project(project_path: &Path) -> Result<Option<Self>> {
let config_path = project_path.join(".tmuxido.toml");
if !config_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read session config: {}", config_path.display()))?;
let config: SessionConfig = toml::from_str(&content).with_context(|| {
format!("Failed to parse session config: {}", config_path.display())
})?;
Ok(Some(config))
}
}
pub struct TmuxSession {
pub(crate) session_name: String,
project_path: String,
}
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(' ', "-");
Self {
session_name,
project_path: project_path.display().to_string(),
}
}
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")?;
let first_target = format!("{}:{}", self.session_name, first_window.name);
if !first_window.panes.is_empty() {
self.create_panes(&first_target, &first_window.panes)?;
}
if let Some(layout) = &first_window.layout {
self.apply_layout(&first_target, layout)?;
}
// Create additional windows, targeting by session name so tmux auto-assigns the index
for window in config.windows.iter().skip(1) {
Command::new("tmux")
.args([
"new-window",
"-t",
&self.session_name,
"-n",
&window.name,
"-c",
&self.project_path,
])
.status()
.with_context(|| format!("Failed to create window: {}", window.name))?;
let target = format!("{}:{}", self.session_name, window.name);
if !window.panes.is_empty() {
self.create_panes(&target, &window.panes)?;
}
if let Some(layout) = &window.layout {
self.apply_layout(&target, layout)?;
}
}
// Select the first window by name
Command::new("tmux")
.args(["select-window", "-t", &first_target])
.status()
.context("Failed to select first window")?;
Ok(())
}
fn create_panes(&self, window_target: &str, panes: &[String]) -> Result<()> {
for (pane_index, command) in panes.iter().enumerate() {
// First pane already exists (created with the window), skip split
if pane_index > 0 {
Command::new("tmux")
.args([
"split-window",
"-t",
window_target,
"-c",
&self.project_path,
])
.status()
.context("Failed to split pane")?;
}
if !command.is_empty() {
let pane_target = format!("{}.{}", window_target, 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_target: &str, layout: &str) -> Result<()> {
Command::new("tmux")
.args(["select-layout", "-t", window_target, layout])
.status()
.with_context(|| format!("Failed to apply layout: {}", layout))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn should_replace_dots_with_underscores_in_session_name() {
let session = TmuxSession::new(Path::new("/home/user/my.project"));
assert_eq!(session.session_name, "my_project");
}
#[test]
fn should_replace_spaces_with_dashes_in_session_name() {
let session = TmuxSession::new(Path::new("/home/user/my project"));
assert_eq!(session.session_name, "my-project");
}
#[test]
fn should_use_project_fallback_when_path_has_no_filename() {
let session = TmuxSession::new(Path::new("/"));
assert_eq!(session.session_name, "project");
}
#[test]
fn should_parse_window_from_toml() {
let toml_str = r#"
[[windows]]
name = "editor"
panes = ["nvim ."]
"#;
let config: SessionConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.windows[0].name, "editor");
assert_eq!(config.windows[0].panes, vec!["nvim ."]);
}
#[test]
fn should_parse_session_config_with_layout() {
let toml_str = r#"
[[windows]]
name = "main"
layout = "tiled"
panes = ["vim", "bash"]
"#;
let config: SessionConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.windows[0].layout, Some("tiled".to_string()));
assert_eq!(config.windows[0].panes.len(), 2);
}
}

View File

@ -1,984 +0,0 @@
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
/// Desktop environment variants we support
#[derive(Debug, PartialEq, Clone)]
pub enum DesktopEnv {
Hyprland,
Gnome,
Kde,
Unknown,
}
impl std::fmt::Display for DesktopEnv {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DesktopEnv::Hyprland => write!(f, "Hyprland"),
DesktopEnv::Gnome => write!(f, "GNOME"),
DesktopEnv::Kde => write!(f, "KDE"),
DesktopEnv::Unknown => write!(f, "Unknown"),
}
}
}
/// A keyboard shortcut combo (modifiers + key), stored in uppercase internally
#[derive(Debug, Clone, PartialEq)]
pub struct KeyCombo {
pub modifiers: Vec<String>,
pub key: String,
}
impl KeyCombo {
/// Parse input like "Super+Shift+T", "super+shift+t", "SUPER+SHIFT+T"
pub fn parse(input: &str) -> Option<Self> {
let trimmed = input.trim();
if trimmed.is_empty() {
return None;
}
let parts: Vec<&str> = trimmed.split('+').collect();
if parts.len() < 2 {
return None;
}
let key = parts.last()?.trim().to_uppercase();
if key.is_empty() {
return None;
}
let modifiers: Vec<String> = parts[..parts.len() - 1]
.iter()
.map(|s| s.trim().to_uppercase())
.filter(|s| !s.is_empty())
.collect();
if modifiers.is_empty() {
return None;
}
Some(KeyCombo { modifiers, key })
}
/// Format for Hyprland binding: "SUPER SHIFT, T"
pub fn to_hyprland(&self) -> String {
let mods = self.modifiers.join(" ");
format!("{}, {}", mods, self.key)
}
/// Format for GNOME gsettings: "<Super><Shift>t"
pub fn to_gnome(&self) -> String {
let mods: String = self
.modifiers
.iter()
.map(|m| {
let mut chars = m.chars();
let capitalized = match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + &chars.as_str().to_lowercase(),
};
format!("<{}>", capitalized)
})
.collect();
format!("{}{}", mods, self.key.to_lowercase())
}
/// Format for KDE kglobalshortcutsrc: "Meta+Shift+T"
pub fn to_kde(&self) -> String {
let mut parts: Vec<String> = self
.modifiers
.iter()
.map(|m| match m.as_str() {
"SUPER" | "WIN" | "META" => "Meta".to_string(),
other => {
let mut chars = other.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + &chars.as_str().to_lowercase(),
}
}
})
.collect();
parts.push(self.key.clone());
parts.join("+")
}
/// Normalized string for dedup/comparison (uppercase, +separated)
pub fn normalized(&self) -> String {
let mut parts = self.modifiers.clone();
parts.push(self.key.clone());
parts.join("+")
}
}
impl std::fmt::Display for KeyCombo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let parts: Vec<String> = self
.modifiers
.iter()
.map(|m| {
let mut chars = m.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + &chars.as_str().to_lowercase(),
}
})
.chain(std::iter::once(self.key.clone()))
.collect();
write!(f, "{}", parts.join("+"))
}
}
// ============================================================================
// Detection
// ============================================================================
/// Detect the current desktop environment from environment variables
pub fn detect_desktop() -> DesktopEnv {
let xdg = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
let has_hyprland_sig = std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok();
detect_from(&xdg, has_hyprland_sig)
}
fn detect_from(xdg: &str, has_hyprland_sig: bool) -> DesktopEnv {
let xdg_lower = xdg.to_lowercase();
if xdg_lower.contains("hyprland") || has_hyprland_sig {
DesktopEnv::Hyprland
} else if xdg_lower.contains("gnome") {
DesktopEnv::Gnome
} else if xdg_lower.contains("kde") || xdg_lower.contains("plasma") {
DesktopEnv::Kde
} else {
DesktopEnv::Unknown
}
}
// ============================================================================
// Hyprland
// ============================================================================
/// Path to the Hyprland bindings config file
pub fn hyprland_bindings_path() -> Result<PathBuf> {
let config_dir = dirs::config_dir().context("Could not determine config directory")?;
Ok(config_dir.join("hypr").join("bindings.conf"))
}
/// Calculate Hyprland modmask bitmask for a key combo
fn hyprland_modmask(combo: &KeyCombo) -> u32 {
let mut mask = 0u32;
for modifier in &combo.modifiers {
mask |= match modifier.as_str() {
"SHIFT" => 1,
"CAPS" => 2,
"CTRL" | "CONTROL" => 4,
"ALT" => 8,
"MOD2" => 16,
"MOD3" => 32,
"SUPER" | "WIN" | "META" => 64,
"MOD5" => 128,
_ => 0,
};
}
mask
}
/// Check if a key combo is already bound in Hyprland via `hyprctl binds -j`.
/// Returns `Some(description)` if a conflict is found, `None` otherwise.
pub fn check_hyprland_conflict(combo: &KeyCombo) -> Option<String> {
let output = std::process::Command::new("hyprctl")
.args(["binds", "-j"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let json_str = String::from_utf8(output.stdout).ok()?;
let binds: Vec<serde_json::Value> = serde_json::from_str(&json_str).ok()?;
let target_modmask = hyprland_modmask(combo);
let target_key = combo.key.to_lowercase();
for bind in &binds {
let modmask = bind["modmask"].as_u64()? as u32;
let key = bind["key"].as_str()?.to_lowercase();
if modmask == target_modmask && key == target_key {
let description = if bind["has_description"].as_bool().unwrap_or(false) {
bind["description"]
.as_str()
.unwrap_or("unknown")
.to_string()
} else {
bind["dispatcher"].as_str().unwrap_or("unknown").to_string()
};
return Some(description);
}
}
None
}
/// Determine the best launch command for Hyprland (prefers omarchy if available)
fn hyprland_launch_command() -> String {
let available = std::process::Command::new("sh")
.args(["-c", "command -v omarchy-launch-tui"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if available {
"omarchy-launch-tui tmuxido".to_string()
} else {
"xdg-terminal-exec -e tmuxido".to_string()
}
}
/// Write a `bindd` entry to the Hyprland bindings file.
/// Skips if any line already contains "tmuxido".
pub fn write_hyprland_binding(path: &Path, combo: &KeyCombo) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
if path.exists() {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
if content.lines().any(|l| l.contains("tmuxido")) {
return Ok(());
}
}
let launch_cmd = hyprland_launch_command();
let line = format!(
"bindd = {}, Tmuxido, exec, {}\n",
combo.to_hyprland(),
launch_cmd
);
use std::fs::OpenOptions;
use std::io::Write;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("Failed to open {}", path.display()))?;
file.write_all(line.as_bytes())
.with_context(|| format!("Failed to write to {}", path.display()))?;
Ok(())
}
// ============================================================================
// GNOME
// ============================================================================
/// Check if a combo conflicts with existing GNOME custom keybindings.
/// Returns `Some(name)` on conflict, `None` otherwise.
pub fn check_gnome_conflict(combo: &KeyCombo) -> Option<String> {
let gnome_binding = combo.to_gnome();
let output = std::process::Command::new("gsettings")
.args([
"get",
"org.gnome.settings-daemon.plugins.media-keys",
"custom-keybindings",
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let list_str = String::from_utf8(output.stdout).ok()?;
let paths = parse_gsettings_list(&list_str);
for path in &paths {
let binding = run_gsettings_custom(path, "binding")?;
if binding.trim_matches('\'') == gnome_binding {
let name = run_gsettings_custom(path, "name").unwrap_or_else(|| "unknown".to_string());
return Some(name.trim_matches('\'').to_string());
}
}
None
}
fn run_gsettings_custom(path: &str, key: &str) -> Option<String> {
let schema = format!(
"org.gnome.settings-daemon.plugins.media-keys.custom-keybindings:{}",
path
);
let output = std::process::Command::new("gsettings")
.args(["get", &schema, key])
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8(output.stdout).ok()?.trim().to_string())
}
/// Parse gsettings list format `['path1', 'path2']` into a vec of path strings.
/// Also handles the GVariant empty-array notation `@as []`.
fn parse_gsettings_list(input: &str) -> Vec<String> {
let s = input.trim();
// Strip GVariant type hint if present: "@as [...]" → "[...]"
let s = s.strip_prefix("@as").map(|r| r.trim()).unwrap_or(s);
let inner = s.trim_start_matches('[').trim_end_matches(']').trim();
if inner.is_empty() {
return Vec::new();
}
inner
.split(',')
.map(|s| s.trim().trim_matches('\'').to_string())
.filter(|s| !s.is_empty())
.collect()
}
/// Write a GNOME custom keybinding using `gsettings`
pub fn write_gnome_shortcut(combo: &KeyCombo) -> Result<()> {
let base_schema = "org.gnome.settings-daemon.plugins.media-keys";
let base_path = "/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings";
let output = std::process::Command::new("gsettings")
.args(["get", base_schema, "custom-keybindings"])
.output()
.context("Failed to run gsettings")?;
let current_list = if output.status.success() {
String::from_utf8(output.stdout)?.trim().to_string()
} else {
"@as []".to_string()
};
let existing = parse_gsettings_list(&current_list);
// Find next available slot number
let slot = (0..)
.find(|n| {
let candidate = format!("{}/custom{}/", base_path, n);
!existing.contains(&candidate)
})
.expect("slot number is always findable");
let slot_path = format!("{}/custom{}/", base_path, slot);
let slot_schema = format!(
"org.gnome.settings-daemon.plugins.media-keys.custom-keybindings:{}",
slot_path
);
let mut new_list = existing.clone();
new_list.push(slot_path.clone());
let list_value = format!(
"[{}]",
new_list
.iter()
.map(|s| format!("'{}'", s))
.collect::<Vec<_>>()
.join(", ")
);
std::process::Command::new("gsettings")
.args(["set", &slot_schema, "name", "Tmuxido"])
.status()
.context("Failed to set GNOME shortcut name")?;
std::process::Command::new("gsettings")
.args(["set", &slot_schema, "binding", &combo.to_gnome()])
.status()
.context("Failed to set GNOME shortcut binding")?;
std::process::Command::new("gsettings")
.args([
"set",
&slot_schema,
"command",
"xdg-terminal-exec -e tmuxido",
])
.status()
.context("Failed to set GNOME shortcut command")?;
std::process::Command::new("gsettings")
.args(["set", base_schema, "custom-keybindings", &list_value])
.status()
.context("Failed to update GNOME custom keybindings list")?;
Ok(())
}
// ============================================================================
// KDE
// ============================================================================
/// Path to the KDE global shortcuts config file
pub fn kde_shortcuts_path() -> Result<PathBuf> {
let config_dir = dirs::config_dir().context("Could not determine config directory")?;
Ok(config_dir.join("kglobalshortcutsrc"))
}
/// Check if a key combo is already bound in `kglobalshortcutsrc`.
/// Returns `Some(section_name)` on conflict, `None` otherwise.
pub fn check_kde_conflict(path: &Path, combo: &KeyCombo) -> Option<String> {
if !path.exists() {
return None;
}
let content = std::fs::read_to_string(path).ok()?;
let kde_combo = combo.to_kde();
let mut current_section = String::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
current_section = trimmed[1..trimmed.len() - 1].to_string();
continue;
}
if let Some(eq_pos) = trimmed.find('=') {
let value = &trimmed[eq_pos + 1..];
// Format: Action=Binding,AlternativeKey,Description
if let Some(binding) = value.split(',').next()
&& binding == kde_combo
{
return Some(current_section.clone());
}
}
}
None
}
/// Write a KDE global shortcut entry to `kglobalshortcutsrc`.
/// Skips if `[tmuxido]` section already exists.
pub fn write_kde_shortcut(path: &Path, combo: &KeyCombo) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
if path.exists() {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
if content.contains("[tmuxido]") {
return Ok(());
}
}
let entry = format!(
"\n[tmuxido]\nLaunch Tmuxido={},none,Launch Tmuxido\n",
combo.to_kde()
);
use std::fs::OpenOptions;
use std::io::Write;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("Failed to open {}", path.display()))?;
file.write_all(entry.as_bytes())
.with_context(|| format!("Failed to write to {}", path.display()))?;
Ok(())
}
// ============================================================================
// Fallback combos and conflict resolution
// ============================================================================
const FALLBACK_COMBOS: &[&str] = &[
"Super+Shift+T",
"Super+Shift+P",
"Super+Ctrl+T",
"Super+Alt+T",
"Super+Shift+M",
"Super+Ctrl+P",
];
/// Find the first free combo from the fallback list, skipping those in `taken`.
/// `taken` should contain normalized combo strings (uppercase, `+`-separated).
pub fn find_free_combo(taken: &[String]) -> Option<KeyCombo> {
FALLBACK_COMBOS.iter().find_map(|s| {
let combo = KeyCombo::parse(s)?;
if taken.contains(&combo.normalized()) {
None
} else {
Some(combo)
}
})
}
// ============================================================================
// Main wizard
// ============================================================================
pub fn setup_shortcut_wizard() -> Result<()> {
let de = detect_desktop();
crate::ui::render_section_header("Keyboard Shortcut");
if de == DesktopEnv::Unknown {
crate::ui::render_shortcut_unknown_de();
return Ok(());
}
println!(" Detected desktop environment: {}", de);
if !crate::ui::render_shortcut_setup_prompt()? {
return Ok(());
}
let combo = loop {
let input = crate::ui::render_key_combo_prompt("Super+Shift+T")?;
let raw = if input.is_empty() {
"Super+Shift+T".to_string()
} else {
input
};
if let Some(c) = KeyCombo::parse(&raw) {
break c;
}
println!(" Invalid key combo. Use format like 'Super+Shift+T'");
};
let conflict = match &de {
DesktopEnv::Hyprland => check_hyprland_conflict(&combo),
DesktopEnv::Gnome => check_gnome_conflict(&combo),
DesktopEnv::Kde => {
let path = kde_shortcuts_path()?;
check_kde_conflict(&path, &combo)
}
DesktopEnv::Unknown => unreachable!(),
};
let final_combo = if let Some(taken_by) = conflict {
let taken_normalized = vec![combo.normalized()];
if let Some(suggestion) = find_free_combo(&taken_normalized) {
let use_suggestion = crate::ui::render_shortcut_conflict_prompt(
&combo.to_string(),
&taken_by,
&suggestion.to_string(),
)?;
if use_suggestion {
suggestion
} else {
println!(" Run 'tmuxido --setup-shortcut' again to choose a different combo.");
return Ok(());
}
} else {
println!(
" All fallback combos are taken. Run 'tmuxido --setup-shortcut' with a custom combo."
);
return Ok(());
}
} else {
combo
};
let (details, reload_hint) = match &de {
DesktopEnv::Hyprland => {
let path = hyprland_bindings_path()?;
write_hyprland_binding(&path, &final_combo)?;
(
format!("Added to {}", path.display()),
"Reload Hyprland with Super+Shift+R to activate.".to_string(),
)
}
DesktopEnv::Gnome => {
write_gnome_shortcut(&final_combo)?;
(
"Added to GNOME custom keybindings.".to_string(),
"The shortcut is active immediately.".to_string(),
)
}
DesktopEnv::Kde => {
let path = kde_shortcuts_path()?;
write_kde_shortcut(&path, &final_combo)?;
(
format!("Added to {}", path.display()),
"Log out and back in to activate the shortcut.".to_string(),
)
}
DesktopEnv::Unknown => unreachable!(),
};
crate::ui::render_shortcut_success(
&de.to_string(),
&final_combo.to_string(),
&details,
&reload_hint,
);
Ok(())
}
// ============================================================================
// Desktop integration (.desktop file + icon)
// ============================================================================
const ICON_URL: &str = "https://raw.githubusercontent.com/cinco/tmuxido/refs/heads/main/docs/assets/tmuxido-icon_96.png";
const DESKTOP_CONTENT: &str = "[Desktop Entry]
Name=Tmuxido
Comment=Quickly find and open projects in tmux
Exec=tmuxido
Icon=tmuxido
Type=Application
Categories=Development;Utility;
Terminal=true
Keywords=tmux;project;fzf;dev;
StartupWMClass=tmuxido
";
/// Path where the .desktop entry will be installed
pub fn desktop_file_path() -> Result<PathBuf> {
let data_dir = dirs::data_dir().context("Could not determine data directory")?;
Ok(data_dir.join("applications").join("tmuxido.desktop"))
}
/// Path where the 96×96 icon will be installed
pub fn icon_install_path() -> Result<PathBuf> {
let data_dir = dirs::data_dir().context("Could not determine data directory")?;
Ok(data_dir
.join("icons")
.join("hicolor")
.join("96x96")
.join("apps")
.join("tmuxido.png"))
}
/// Result of a desktop integration install
pub struct DesktopInstallResult {
pub desktop_path: PathBuf,
pub icon_path: PathBuf,
pub icon_downloaded: bool,
}
/// Write the .desktop file and download the icon to the given paths.
/// Icon download is best-effort — does not fail if curl or network is unavailable.
pub fn install_desktop_integration_to(
desktop_path: &Path,
icon_path: &Path,
) -> Result<DesktopInstallResult> {
// Write .desktop
if let Some(parent) = desktop_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
std::fs::write(desktop_path, DESKTOP_CONTENT)
.with_context(|| format!("Failed to write {}", desktop_path.display()))?;
// Download icon (best-effort via curl)
let icon_downloaded = (|| -> Option<()> {
if let Some(parent) = icon_path.parent() {
std::fs::create_dir_all(parent).ok()?;
}
std::process::Command::new("curl")
.args(["-fsSL", ICON_URL, "-o", &icon_path.to_string_lossy()])
.status()
.ok()?
.success()
.then_some(())
})()
.is_some();
// Refresh desktop database (best-effort)
if let Some(apps_dir) = desktop_path.parent() {
let _ = std::process::Command::new("update-desktop-database")
.arg(apps_dir)
.status();
}
// Refresh icon cache (best-effort)
if icon_downloaded {
// Navigate up from …/96x96/apps → …/icons/hicolor
let hicolor_dir = icon_path
.parent()
.and_then(|p| p.parent())
.and_then(|p| p.parent());
if let Some(dir) = hicolor_dir {
let _ = std::process::Command::new("gtk-update-icon-cache")
.args(["-f", "-t", &dir.to_string_lossy()])
.status();
}
}
Ok(DesktopInstallResult {
desktop_path: desktop_path.to_path_buf(),
icon_path: icon_path.to_path_buf(),
icon_downloaded,
})
}
/// Install .desktop and icon to the standard XDG locations
pub fn install_desktop_integration() -> Result<DesktopInstallResult> {
install_desktop_integration_to(&desktop_file_path()?, &icon_install_path()?)
}
/// Interactive wizard that asks the user and then installs desktop integration
pub fn setup_desktop_integration_wizard() -> Result<()> {
crate::ui::render_section_header("Desktop Integration");
if !crate::ui::render_desktop_integration_prompt()? {
return Ok(());
}
let result = install_desktop_integration()?;
crate::ui::render_desktop_integration_success(&result);
Ok(())
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
// --- detect_desktop ---
#[test]
fn should_detect_hyprland_from_xdg_var() {
assert_eq!(detect_from("Hyprland", false), DesktopEnv::Hyprland);
assert_eq!(detect_from("hyprland", false), DesktopEnv::Hyprland);
assert_eq!(detect_from("HYPRLAND", false), DesktopEnv::Hyprland);
}
#[test]
fn should_detect_hyprland_from_signature_even_without_xdg() {
assert_eq!(detect_from("", true), DesktopEnv::Hyprland);
assert_eq!(detect_from("somethingelse", true), DesktopEnv::Hyprland);
}
#[test]
fn should_detect_gnome() {
assert_eq!(detect_from("GNOME", false), DesktopEnv::Gnome);
assert_eq!(detect_from("gnome", false), DesktopEnv::Gnome);
assert_eq!(detect_from("ubuntu:GNOME", false), DesktopEnv::Gnome);
}
#[test]
fn should_detect_kde_from_kde_xdg() {
assert_eq!(detect_from("KDE", false), DesktopEnv::Kde);
assert_eq!(detect_from("kde", false), DesktopEnv::Kde);
}
#[test]
fn should_detect_kde_from_plasma_xdg() {
assert_eq!(detect_from("Plasma", false), DesktopEnv::Kde);
assert_eq!(detect_from("plasma", false), DesktopEnv::Kde);
}
#[test]
fn should_return_unknown_for_unrecognized_de() {
assert_eq!(detect_from("", false), DesktopEnv::Unknown);
assert_eq!(detect_from("i3", false), DesktopEnv::Unknown);
assert_eq!(detect_from("sway", false), DesktopEnv::Unknown);
}
// --- KeyCombo::parse ---
#[test]
fn should_parse_title_case_combo() {
let c = KeyCombo::parse("Super+Shift+T").unwrap();
assert_eq!(c.modifiers, vec!["SUPER", "SHIFT"]);
assert_eq!(c.key, "T");
}
#[test]
fn should_parse_lowercase_combo() {
let c = KeyCombo::parse("super+shift+t").unwrap();
assert_eq!(c.modifiers, vec!["SUPER", "SHIFT"]);
assert_eq!(c.key, "T");
}
#[test]
fn should_parse_uppercase_combo() {
let c = KeyCombo::parse("SUPER+SHIFT+T").unwrap();
assert_eq!(c.modifiers, vec!["SUPER", "SHIFT"]);
assert_eq!(c.key, "T");
}
#[test]
fn should_parse_three_modifier_combo() {
let c = KeyCombo::parse("Super+Ctrl+Alt+F").unwrap();
assert_eq!(c.modifiers, vec!["SUPER", "CTRL", "ALT"]);
assert_eq!(c.key, "F");
}
#[test]
fn should_return_none_for_key_only() {
assert!(KeyCombo::parse("T").is_none());
}
#[test]
fn should_return_none_for_empty_input() {
assert!(KeyCombo::parse("").is_none());
assert!(KeyCombo::parse(" ").is_none());
}
#[test]
fn should_trim_whitespace_in_parts() {
let c = KeyCombo::parse(" Super + Shift + T ").unwrap();
assert_eq!(c.modifiers, vec!["SUPER", "SHIFT"]);
assert_eq!(c.key, "T");
}
// --- KeyCombo formatting ---
#[test]
fn should_format_for_hyprland() {
let c = KeyCombo::parse("Super+Shift+T").unwrap();
assert_eq!(c.to_hyprland(), "SUPER SHIFT, T");
}
#[test]
fn should_format_single_modifier_for_hyprland() {
let c = KeyCombo::parse("Super+T").unwrap();
assert_eq!(c.to_hyprland(), "SUPER, T");
}
#[test]
fn should_format_for_gnome() {
let c = KeyCombo::parse("Super+Shift+T").unwrap();
assert_eq!(c.to_gnome(), "<Super><Shift>t");
}
#[test]
fn should_format_ctrl_for_gnome() {
let c = KeyCombo::parse("Super+Ctrl+P").unwrap();
assert_eq!(c.to_gnome(), "<Super><Ctrl>p");
}
#[test]
fn should_format_for_kde() {
let c = KeyCombo::parse("Super+Shift+T").unwrap();
assert_eq!(c.to_kde(), "Meta+Shift+T");
}
#[test]
fn should_map_super_to_meta_for_kde() {
let c = KeyCombo::parse("Super+Ctrl+P").unwrap();
assert_eq!(c.to_kde(), "Meta+Ctrl+P");
}
#[test]
fn should_display_in_title_case() {
let c = KeyCombo::parse("SUPER+SHIFT+T").unwrap();
assert_eq!(c.to_string(), "Super+Shift+T");
}
// --- hyprland_modmask ---
#[test]
fn should_calculate_modmask_for_super_shift() {
let c = KeyCombo::parse("Super+Shift+T").unwrap();
assert_eq!(hyprland_modmask(&c), 64 + 1); // SUPER=64, SHIFT=1
}
#[test]
fn should_calculate_modmask_for_super_only() {
let c = KeyCombo::parse("Super+T").unwrap();
assert_eq!(hyprland_modmask(&c), 64);
}
#[test]
fn should_calculate_modmask_for_ctrl_alt() {
let c = KeyCombo::parse("Ctrl+Alt+T").unwrap();
assert_eq!(hyprland_modmask(&c), 4 + 8); // CTRL=4, ALT=8
}
// --- find_free_combo ---
#[test]
fn should_return_first_fallback_when_nothing_taken() {
let combo = find_free_combo(&[]).unwrap();
assert_eq!(combo.normalized(), "SUPER+SHIFT+T");
}
#[test]
fn should_skip_taken_combos() {
let taken = vec!["SUPER+SHIFT+T".to_string(), "SUPER+SHIFT+P".to_string()];
let combo = find_free_combo(&taken).unwrap();
assert_eq!(combo.normalized(), "SUPER+CTRL+T");
}
#[test]
fn should_return_none_when_all_fallbacks_taken() {
let taken: Vec<String> = FALLBACK_COMBOS
.iter()
.map(|s| KeyCombo::parse(s).unwrap().normalized())
.collect();
assert!(find_free_combo(&taken).is_none());
}
// --- parse_gsettings_list ---
#[test]
fn should_parse_empty_gsettings_list() {
assert!(parse_gsettings_list("[]").is_empty());
assert!(parse_gsettings_list("@as []").is_empty());
assert!(parse_gsettings_list(" [ ] ").is_empty());
}
#[test]
fn should_parse_gsettings_list_with_one_entry() {
let result =
parse_gsettings_list("['/org/gnome/settings-daemon/plugins/media-keys/custom0/']");
assert_eq!(
result,
vec!["/org/gnome/settings-daemon/plugins/media-keys/custom0/"]
);
}
#[test]
fn should_parse_gsettings_list_with_multiple_entries() {
let result = parse_gsettings_list("['/org/gnome/.../custom0/', '/org/gnome/.../custom1/']");
assert_eq!(result.len(), 2);
assert_eq!(result[0], "/org/gnome/.../custom0/");
assert_eq!(result[1], "/org/gnome/.../custom1/");
}
// --- check_kde_conflict ---
#[test]
fn should_return_none_when_kde_file_missing() {
let combo = KeyCombo::parse("Super+Shift+T").unwrap();
assert!(check_kde_conflict(Path::new("/nonexistent/path"), &combo).is_none());
}
// --- normalized ---
#[test]
fn should_normalize_to_uppercase_plus_separated() {
let c = KeyCombo::parse("super+shift+t").unwrap();
assert_eq!(c.normalized(), "SUPER+SHIFT+T");
}
// --- desktop integration ---
#[test]
fn should_write_desktop_file_to_given_path() {
let dir = tempfile::tempdir().unwrap();
let desktop = dir.path().join("apps").join("tmuxido.desktop");
let icon = dir.path().join("icons").join("tmuxido.png");
let result = install_desktop_integration_to(&desktop, &icon).unwrap();
assert!(result.desktop_path.exists());
let content = std::fs::read_to_string(&result.desktop_path).unwrap();
assert!(content.contains("[Desktop Entry]"));
assert!(content.contains("Exec=tmuxido"));
assert!(content.contains("Icon=tmuxido"));
assert!(content.contains("Terminal=true"));
}
#[test]
fn should_create_parent_directories_for_desktop_file() {
let dir = tempfile::tempdir().unwrap();
let desktop = dir
.path()
.join("nested")
.join("apps")
.join("tmuxido.desktop");
let icon = dir.path().join("icons").join("tmuxido.png");
install_desktop_integration_to(&desktop, &icon).unwrap();
assert!(desktop.exists());
}
#[test]
fn desktop_content_contains_required_fields() {
assert!(DESKTOP_CONTENT.contains("[Desktop Entry]"));
assert!(DESKTOP_CONTENT.contains("Name=Tmuxido"));
assert!(DESKTOP_CONTENT.contains("Exec=tmuxido"));
assert!(DESKTOP_CONTENT.contains("Icon=tmuxido"));
assert!(DESKTOP_CONTENT.contains("Type=Application"));
assert!(DESKTOP_CONTENT.contains("Terminal=true"));
assert!(DESKTOP_CONTENT.contains("StartupWMClass=tmuxido"));
}
}

1016
src/ui.rs

File diff suppressed because it is too large Load Diff

View File

@ -1,219 +0,0 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::config::Config;
use crate::self_update;
#[derive(Debug, Default, Serialize, Deserialize)]
struct UpdateCheckCache {
last_checked: u64,
latest_version: String,
}
pub fn check_and_notify(config: &Config) {
let cache = load_cache();
check_and_notify_internal(
config.update_check_interval_hours,
cache,
&|| self_update::fetch_latest_tag(),
&save_cache,
);
}
fn check_and_notify_internal(
interval_hours: u64,
mut cache: UpdateCheckCache,
fetcher: &dyn Fn() -> Result<String>,
saver: &dyn Fn(&UpdateCheckCache),
) -> bool {
if interval_hours == 0 {
return false;
}
let elapsed = elapsed_hours(cache.last_checked);
if elapsed >= interval_hours
&& let Ok(latest) = fetcher()
{
let latest_clean = latest.trim_start_matches('v').to_string();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
cache.last_checked = now;
cache.latest_version = latest_clean;
saver(&cache);
}
let current = self_update::current_version();
let latest_clean = cache.latest_version.trim_start_matches('v');
if !latest_clean.is_empty()
&& self_update::version_compare(latest_clean, current) == std::cmp::Ordering::Greater
{
print_update_notice(current, latest_clean);
return true;
}
false
}
fn print_update_notice(current: &str, latest: &str) {
let msg1 = format!(" Update available: {} \u{2192} {} ", current, latest);
let msg2 = " Run tmuxido --update to install. ";
let w1 = msg1.chars().count();
let w2 = msg2.chars().count();
let width = w1.max(w2);
let border = "\u{2500}".repeat(width);
println!("\u{250c}{}\u{2510}", border);
println!("\u{2502}{}\u{2502}", pad_to_chars(&msg1, width));
println!("\u{2502}{}\u{2502}", pad_to_chars(msg2, width));
println!("\u{2514}{}\u{2518}", border);
}
fn pad_to_chars(s: &str, width: usize) -> String {
let char_count = s.chars().count();
if char_count >= width {
s.to_string()
} else {
format!("{}{}", s, " ".repeat(width - char_count))
}
}
fn cache_path() -> Result<PathBuf> {
let cache_dir = dirs::cache_dir()
.ok_or_else(|| anyhow::anyhow!("Could not determine cache directory"))?
.join("tmuxido");
Ok(cache_dir.join("update_check.json"))
}
fn load_cache() -> UpdateCheckCache {
cache_path()
.ok()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn save_cache(cache: &UpdateCheckCache) {
if let Ok(path) = cache_path() {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(json) = serde_json::to_string(cache) {
let _ = std::fs::write(path, json);
}
}
}
fn elapsed_hours(ts: u64) -> u64 {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
now.saturating_sub(ts) / 3600
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
fn make_cache(last_checked: u64, latest_version: &str) -> UpdateCheckCache {
UpdateCheckCache {
last_checked,
latest_version: latest_version.to_string(),
}
}
fn now_ts() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[test]
fn should_not_notify_when_interval_is_zero() {
let cache = make_cache(0, "99.0.0");
let fetcher_called = RefCell::new(false);
let result = check_and_notify_internal(
0,
cache,
&|| {
*fetcher_called.borrow_mut() = true;
Ok("99.0.0".to_string())
},
&|_| {},
);
assert!(!result);
assert!(!fetcher_called.into_inner());
}
#[test]
fn should_not_check_when_interval_not_elapsed() {
let cache = make_cache(now_ts(), "");
let fetcher_called = RefCell::new(false);
check_and_notify_internal(
24,
cache,
&|| {
*fetcher_called.borrow_mut() = true;
Ok("99.0.0".to_string())
},
&|_| {},
);
assert!(!fetcher_called.into_inner());
}
#[test]
fn should_check_when_interval_elapsed() {
let cache = make_cache(0, "");
let fetcher_called = RefCell::new(false);
check_and_notify_internal(
1,
cache,
&|| {
*fetcher_called.borrow_mut() = true;
Ok(self_update::current_version().to_string())
},
&|_| {},
);
assert!(fetcher_called.into_inner());
}
#[test]
fn should_not_notify_when_versions_equal() {
let current = self_update::current_version();
let cache = make_cache(now_ts(), current);
let result = check_and_notify_internal(24, cache, &|| unreachable!(), &|_| {});
assert!(!result);
}
#[test]
fn should_detect_update_available() {
let cache = make_cache(now_ts(), "99.0.0");
let result = check_and_notify_internal(24, cache, &|| unreachable!(), &|_| {});
assert!(result);
}
#[test]
fn should_not_detect_update_when_current_is_newer() {
let cache = make_cache(now_ts(), "0.0.1");
let result = check_and_notify_internal(24, cache, &|| unreachable!(), &|_| {});
assert!(!result);
}
}

View File

@ -1,13 +0,0 @@
use std::collections::HashMap;
use std::path::PathBuf;
use tmuxido::cache::ProjectCache;
#[test]
fn should_save_and_reload_cache() {
let projects = vec![PathBuf::from("/tmp/test_tmuxido_project")];
let cache = ProjectCache::new(projects.clone(), HashMap::new());
cache.save().unwrap();
let loaded = ProjectCache::load().unwrap().unwrap();
assert_eq!(loaded.projects, projects);
}

View File

@ -1,137 +0,0 @@
use tmuxido::deps::{
BinaryChecker, Dep, PackageManager, SystemBinaryChecker, check_missing, detect_package_manager,
};
// --- SystemBinaryChecker (real system calls) ---
#[test]
fn system_checker_finds_sh_binary() {
let checker = SystemBinaryChecker;
assert!(
checker.is_available("sh"),
"`sh` must be present on any Unix system"
);
}
#[test]
fn system_checker_returns_false_for_nonexistent_binary() {
let checker = SystemBinaryChecker;
assert!(!checker.is_available("tmuxido_nonexistent_xyz_42"));
}
// --- detect_package_manager on real system ---
#[test]
fn should_detect_some_package_manager_on_linux() {
let checker = SystemBinaryChecker;
let pm = detect_package_manager(&checker);
assert!(
pm.is_some(),
"Expected to detect at least one package manager on this Linux system"
);
}
// --- PackageManager metadata completeness ---
#[test]
fn all_package_managers_have_non_empty_detection_binary() {
for pm in PackageManager::all_ordered() {
assert!(
!pm.detection_binary().is_empty(),
"{:?} has empty detection binary",
pm
);
}
}
#[test]
fn all_package_managers_have_non_empty_display_name() {
for pm in PackageManager::all_ordered() {
assert!(
!pm.display_name().is_empty(),
"{:?} has empty display name",
pm
);
}
}
#[test]
fn install_command_always_starts_with_sudo() {
let packages = &["fzf", "tmux"];
for pm in PackageManager::all_ordered() {
let cmd = pm.install_command(packages);
assert_eq!(
cmd.first().map(String::as_str),
Some("sudo"),
"{} install command should start with sudo",
pm.display_name()
);
}
}
#[test]
fn install_command_always_contains_requested_packages() {
let packages = &["fzf", "tmux"];
for pm in PackageManager::all_ordered() {
let cmd = pm.install_command(packages);
assert!(
cmd.contains(&"fzf".to_string()),
"{} command missing 'fzf'",
pm.display_name()
);
assert!(
cmd.contains(&"tmux".to_string()),
"{} command missing 'tmux'",
pm.display_name()
);
}
}
// --- Dep completeness ---
#[test]
fn dep_package_names_are_standard() {
assert_eq!(Dep::Fzf.package_name(), "fzf");
assert_eq!(Dep::Tmux.package_name(), "tmux");
}
#[test]
fn all_deps_have_matching_binary_and_package_names() {
for dep in Dep::all() {
assert!(!dep.binary_name().is_empty());
assert!(!dep.package_name().is_empty());
}
}
// --- check_missing on real system ---
#[test]
fn check_missing_returns_only_actually_missing_tools() {
let checker = SystemBinaryChecker;
let missing = check_missing(&checker);
// Every item reported as missing must NOT be findable via `which`
for dep in &missing {
assert!(
!checker.is_available(dep.binary_name()),
"{} reported as missing but `which` finds it",
dep.binary_name()
);
}
}
#[test]
fn check_missing_does_not_report_present_tools_as_missing() {
let checker = SystemBinaryChecker;
let missing = check_missing(&checker);
// Every dep NOT in missing list must be available
let missing_names: Vec<&str> = missing.iter().map(|d| d.binary_name()).collect();
for dep in Dep::all() {
if !missing_names.contains(&dep.binary_name()) {
assert!(
checker.is_available(dep.binary_name()),
"{} not in missing list but `which` can't find it",
dep.binary_name()
);
}
}
}

View File

@ -1,43 +0,0 @@
# syntax=docker/dockerfile:1
# ---- Stage 1: Build (Rust stable on Debian slim) ----
FROM rust:1-slim AS builder
WORKDIR /src
# Copy manifests first so cargo can resolve deps (layer cache friendly)
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
# Copy source and build release binary
COPY src/ ./src/
RUN cargo build --release --locked
# ---- Stage 2: Test environment (fresh Ubuntu, no fzf/tmux) ----
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
# Install only what's needed to run the test suite itself
# (git + sudo so Test 7 can install fzf/tmux via apt)
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
sudo \
git \
&& rm -rf /var/lib/apt/lists/*
# Create an unprivileged user with passwordless sudo
# (simulates a regular developer who can install packages)
RUN useradd -m -s /bin/bash testuser \
&& echo "testuser ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
# Install the tmuxido binary built in stage 1
COPY --from=builder /src/target/release/tmuxido /usr/local/bin/tmuxido
# Copy and register the test entrypoint
COPY tests/docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
USER testuser
WORKDIR /home/testuser
ENTRYPOINT ["entrypoint.sh"]

View File

@ -1,185 +0,0 @@
#!/usr/bin/env bash
# Test suite executed inside the Ubuntu container.
# Simulates a brand-new user running tmuxido for the first time.
set -uo pipefail
PASS=0
FAIL=0
pass() { echo "$1"; PASS=$((PASS + 1)); }
fail() { echo "$1"; FAIL=$((FAIL + 1)); }
section() {
echo ""
echo "┌─ $1"
}
# ---------------------------------------------------------------------------
# Phase 1 — fzf and tmux are NOT installed yet
# ---------------------------------------------------------------------------
echo ""
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ tmuxido — Container Integration Tests (Ubuntu 24.04) ║"
echo "╚══════════════════════════════════════════════════════════╝"
section "Phase 1: binary basics"
# T1 — binary is in PATH and executable
if command -v tmuxido &>/dev/null; then
pass "tmuxido found in PATH ($(command -v tmuxido))"
else
fail "tmuxido not found in PATH"
fi
# T2 — --help exits 0
if tmuxido --help >/dev/null 2>&1; then
pass "--help exits with code 0"
else
fail "--help returned non-zero"
fi
# T3 — --version shows the package name
VERSION_OUT=$(tmuxido --version 2>&1 || true)
if echo "$VERSION_OUT" | grep -q "tmuxido"; then
pass "--version output contains 'tmuxido' → $VERSION_OUT"
else
fail "--version output unexpected: $VERSION_OUT"
fi
# ---------------------------------------------------------------------------
# Phase 2 — dependency detection (fzf and tmux absent)
# ---------------------------------------------------------------------------
section "Phase 2: dependency detection (fzf and tmux not installed)"
# Pipe "n" so tmuxido declines to install and exits
DEP_OUT=$(echo "n" | tmuxido 2>&1 || true)
# T4 — fzf reported as missing
if echo "$DEP_OUT" | grep -q "fzf"; then
pass "fzf detected as missing"
else
fail "fzf NOT detected as missing. Full output:\n$DEP_OUT"
fi
# T5 — tmux reported as missing
if echo "$DEP_OUT" | grep -q "tmux"; then
pass "tmux detected as missing"
else
fail "tmux NOT detected as missing. Full output:\n$DEP_OUT"
fi
# T6 — "not installed" heading appears
if echo "$DEP_OUT" | grep -q "not installed"; then
pass "User-facing 'not installed' message shown"
else
fail "'not installed' message missing. Full output:\n$DEP_OUT"
fi
# T7 — apt detected as package manager (Ubuntu 24.04)
if echo "$DEP_OUT" | grep -q "apt"; then
pass "apt detected as the package manager"
else
fail "apt NOT detected. Full output:\n$DEP_OUT"
fi
# T8 — install command includes sudo apt install
if echo "$DEP_OUT" | grep -q "sudo apt install"; then
pass "Install command 'sudo apt install' shown to user"
else
fail "Install command incorrect. Full output:\n$DEP_OUT"
fi
# T9 — cancellation message when user answers "n"
if echo "$DEP_OUT" | grep -q "cancelled\|Cancelled\|manually"; then
pass "Graceful cancellation message shown"
else
fail "Cancellation message missing. Full output:\n$DEP_OUT"
fi
# ---------------------------------------------------------------------------
# Phase 3 — install deps and run full workflow
# ---------------------------------------------------------------------------
section "Phase 3: full workflow (after installing fzf, tmux and git)"
echo " Installing fzf, tmux via apt (this may take a moment)..."
sudo apt-get update -qq 2>/dev/null
sudo apt-get install -y --no-install-recommends fzf tmux 2>/dev/null
# T10 — fzf now available
if command -v fzf &>/dev/null; then
pass "fzf installed successfully ($(fzf --version 2>&1 | head -1))"
else
fail "fzf still not available after installation"
fi
# T11 — tmux now available
if command -v tmux &>/dev/null; then
pass "tmux installed successfully ($(tmux -V))"
else
fail "tmux still not available after installation"
fi
# T12 — tmuxido no longer triggers dependency prompt
NO_DEP_OUT=$(echo "" | tmuxido 2>&1 || true)
if echo "$NO_DEP_OUT" | grep -q "not installed"; then
fail "Dependency prompt still shown after installing deps"
else
pass "No dependency prompt after deps are installed"
fi
# T13 — set up a minimal git project tree for scanning
mkdir -p ~/Projects/demo-app
git -C ~/Projects/demo-app init --quiet
git -C ~/Projects/demo-app config user.email "test@test.com"
git -C ~/Projects/demo-app config user.name "Test"
mkdir -p ~/.config/tmuxido
cat > ~/.config/tmuxido/tmuxido.toml <<'EOF'
paths = ["~/Projects"]
max_depth = 3
cache_enabled = true
EOF
# T13 — --refresh scans and finds our demo project
REFRESH_OUT=$(tmuxido --refresh 2>&1 || true)
if echo "$REFRESH_OUT" | grep -q "projects\|Projects"; then
pass "--refresh scanned and reported projects"
else
fail "--refresh output unexpected: $REFRESH_OUT"
fi
# T14 — --cache-status reports the cache that was just built
CACHE_OUT=$(tmuxido --cache-status 2>&1 || true)
if echo "$CACHE_OUT" | grep -qi "cache"; then
pass "--cache-status reports cache info"
else
fail "--cache-status output unexpected: $CACHE_OUT"
fi
# T15 — cache contains our demo project
if echo "$CACHE_OUT" | grep -q "Projects cached: [^0]"; then
pass "Cache contains at least 1 project"
else
# Try alternate grep in case format differs
if echo "$CACHE_OUT" | grep -q "cached:"; then
pass "--cache-status shows cached projects (count check skipped)"
else
fail "Cache appears empty. Output: $CACHE_OUT"
fi
fi
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
echo ""
echo "╔══════════════════════════════════════════════════════════╗"
printf "║ Results: %-3d passed, %-3d failed%*s║\n" \
"$PASS" "$FAIL" $((24 - ${#PASS} - ${#FAIL})) ""
echo "╚══════════════════════════════════════════════════════════╝"
echo ""
[ "$FAIL" -eq 0 ]

View File

@ -1,54 +0,0 @@
#!/usr/bin/env bash
# Build the tmuxido Docker test image and run the container integration tests.
#
# Usage:
# ./tests/docker/run.sh # build + run
# ./tests/docker/run.sh --no-cache # force rebuild from scratch
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
IMAGE_NAME="tmuxido-test"
# Propagate --no-cache if requested
BUILD_FLAGS=()
if [[ "${1:-}" == "--no-cache" ]]; then
BUILD_FLAGS+=(--no-cache)
fi
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ tmuxido — Docker Integration Test Runner ║"
echo "╚══════════════════════════════════════════════════════════╝"
echo ""
echo "Project root : $PROJECT_ROOT"
echo "Dockerfile : $SCRIPT_DIR/Dockerfile"
echo "Image name : $IMAGE_NAME"
echo ""
# ---- Build ----------------------------------------------------------------
echo "Building image (stage 1: rust compile, stage 2: ubuntu test env)..."
docker build \
"${BUILD_FLAGS[@]}" \
--tag "$IMAGE_NAME" \
--file "$SCRIPT_DIR/Dockerfile" \
"$PROJECT_ROOT"
echo ""
echo "Build complete. Running tests..."
echo ""
# ---- Run ------------------------------------------------------------------
docker run \
--rm \
--name "${IMAGE_NAME}-run" \
"$IMAGE_NAME"
EXIT=$?
if [ "$EXIT" -eq 0 ]; then
echo "All tests passed."
else
echo "Some tests FAILED (exit $EXIT)."
fi
exit "$EXIT"

View File

@ -1,66 +0,0 @@
use std::fs;
use tempfile::tempdir;
use tmuxido::config::Config;
use tmuxido::scan_from_root;
use tmuxido::session::SessionConfig;
fn make_config(max_depth: usize) -> Config {
Config {
paths: vec![],
max_depth,
cache_enabled: true,
cache_ttl_hours: 24,
update_check_interval_hours: 24,
default_session: SessionConfig { windows: vec![] },
}
}
/// `tempfile::tempdir()` creates hidden dirs (e.g. `/tmp/.tmpXXXXXX`) on this
/// system, which `scan_from_root`'s `filter_entry` would skip. Create a
/// visible subdirectory to use as the actual scan root.
fn make_scan_root() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempdir().unwrap();
let root = dir.path().join("scan_root");
fs::create_dir_all(&root).unwrap();
(dir, root)
}
#[test]
fn should_find_git_repos_in_temp_dir() {
let (_dir, root) = make_scan_root();
fs::create_dir_all(root.join("foo/.git")).unwrap();
fs::create_dir_all(root.join("bar/.git")).unwrap();
let config = make_config(5);
let (projects, _) = scan_from_root(&root, &config).unwrap();
assert_eq!(projects.len(), 2);
assert!(projects.iter().any(|p| p.ends_with("foo")));
assert!(projects.iter().any(|p| p.ends_with("bar")));
}
#[test]
fn should_not_descend_into_hidden_dirs() {
let (_dir, root) = make_scan_root();
fs::create_dir_all(root.join(".hidden/repo/.git")).unwrap();
let config = make_config(5);
let (projects, _) = scan_from_root(&root, &config).unwrap();
assert!(projects.is_empty());
}
#[test]
fn should_respect_max_depth() {
let (_dir, root) = make_scan_root();
// Shallow: project/.git at depth 2 from root — found with max_depth=2
fs::create_dir_all(root.join("project/.git")).unwrap();
// Deep: nested/deep/project/.git at depth 4 — excluded with max_depth=2
fs::create_dir_all(root.join("nested/deep/project/.git")).unwrap();
let config = make_config(2);
let (projects, _) = scan_from_root(&root, &config).unwrap();
assert_eq!(projects.len(), 1);
assert!(projects[0].ends_with("project"));
}

View File

@ -1,26 +0,0 @@
use std::fs;
use tempfile::tempdir;
use tmuxido::session::SessionConfig;
#[test]
fn should_load_project_session_config() {
let dir = tempdir().unwrap();
let config_content = r#"
[[windows]]
name = "editor"
panes = ["nvim ."]
"#;
fs::write(dir.path().join(".tmuxido.toml"), config_content).unwrap();
let result = SessionConfig::load_from_project(dir.path()).unwrap();
assert!(result.is_some());
let config = result.unwrap();
assert_eq!(config.windows[0].name, "editor");
}
#[test]
fn should_return_none_when_no_project_config() {
let dir = tempdir().unwrap();
let result = SessionConfig::load_from_project(dir.path()).unwrap();
assert!(result.is_none());
}

View File

@ -1,165 +0,0 @@
use std::fs;
use tempfile::tempdir;
use tmuxido::shortcut::{
KeyCombo, check_kde_conflict, install_desktop_integration_to, write_hyprland_binding,
write_kde_shortcut,
};
#[test]
fn writes_hyprland_binding_to_new_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("bindings.conf");
let combo = KeyCombo::parse("Super+Shift+T").unwrap();
write_hyprland_binding(&path, &combo).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(
content.contains("SUPER SHIFT, T"),
"should contain Hyprland combo"
);
assert!(content.contains("tmuxido"), "should mention tmuxido");
assert!(
content.starts_with("bindd"),
"should start with bindd directive"
);
}
#[test]
fn write_hyprland_binding_skips_when_tmuxido_already_present() {
let dir = tempdir().unwrap();
let path = dir.path().join("bindings.conf");
fs::write(&path, "bindd = SUPER SHIFT, T, Tmuxido, exec, tmuxido\n").unwrap();
let combo = KeyCombo::parse("Super+Shift+T").unwrap();
write_hyprland_binding(&path, &combo).unwrap();
let content = fs::read_to_string(&path).unwrap();
let count = content.lines().filter(|l| l.contains("tmuxido")).count();
assert_eq!(count, 1, "should not add a duplicate line");
}
#[test]
fn write_hyprland_binding_creates_parent_dirs() {
let dir = tempdir().unwrap();
let path = dir.path().join("nested").join("hypr").join("bindings.conf");
let combo = KeyCombo::parse("Super+Ctrl+T").unwrap();
write_hyprland_binding(&path, &combo).unwrap();
assert!(
path.exists(),
"file should be created even when parent dirs are missing"
);
}
#[test]
fn writes_kde_shortcut_to_new_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("kglobalshortcutsrc");
let combo = KeyCombo::parse("Super+Shift+T").unwrap();
write_kde_shortcut(&path, &combo).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(
content.contains("[tmuxido]"),
"should contain [tmuxido] section"
);
assert!(
content.contains("Meta+Shift+T"),
"should use Meta notation for KDE"
);
assert!(
content.contains("Launch Tmuxido"),
"should include action description"
);
}
#[test]
fn write_kde_shortcut_skips_when_section_already_exists() {
let dir = tempdir().unwrap();
let path = dir.path().join("kglobalshortcutsrc");
fs::write(
&path,
"[tmuxido]\nLaunch Tmuxido=Meta+Shift+T,none,Launch Tmuxido\n",
)
.unwrap();
let combo = KeyCombo::parse("Super+Shift+P").unwrap();
write_kde_shortcut(&path, &combo).unwrap();
let content = fs::read_to_string(&path).unwrap();
let count = content.matches("[tmuxido]").count();
assert_eq!(count, 1, "should not add a duplicate section");
}
#[test]
fn check_kde_conflict_finds_existing_binding() {
let dir = tempdir().unwrap();
let path = dir.path().join("kglobalshortcutsrc");
fs::write(
&path,
"[myapp]\nLaunch Something=Meta+Shift+T,none,Launch Something\n",
)
.unwrap();
let combo = KeyCombo::parse("Super+Shift+T").unwrap();
let conflict = check_kde_conflict(&path, &combo);
assert_eq!(conflict, Some("myapp".to_string()));
}
#[test]
fn check_kde_conflict_returns_none_for_free_binding() {
let dir = tempdir().unwrap();
let path = dir.path().join("kglobalshortcutsrc");
fs::write(
&path,
"[myapp]\nLaunch Something=Meta+Ctrl+T,none,Launch Something\n",
)
.unwrap();
let combo = KeyCombo::parse("Super+Shift+T").unwrap();
assert!(check_kde_conflict(&path, &combo).is_none());
}
#[test]
fn check_kde_conflict_returns_none_when_file_missing() {
let combo = KeyCombo::parse("Super+Shift+T").unwrap();
assert!(check_kde_conflict(std::path::Path::new("/nonexistent/path"), &combo).is_none());
}
#[test]
fn installs_desktop_file_to_given_path() {
let dir = tempdir().unwrap();
let desktop_path = dir.path().join("applications").join("tmuxido.desktop");
let icon_path = dir
.path()
.join("icons")
.join("hicolor")
.join("96x96")
.join("apps")
.join("tmuxido.png");
let result = install_desktop_integration_to(&desktop_path, &icon_path).unwrap();
assert!(result.desktop_path.exists(), ".desktop file should exist");
let content = fs::read_to_string(&result.desktop_path).unwrap();
assert!(content.contains("[Desktop Entry]"));
assert!(content.contains("Exec=tmuxido"));
assert!(content.contains("Icon=tmuxido"));
assert!(content.contains("Terminal=true"));
assert!(content.contains("StartupWMClass=tmuxido"));
}
#[test]
fn desktop_install_creates_parent_dirs() {
let dir = tempdir().unwrap();
let desktop_path = dir.path().join("a").join("b").join("tmuxido.desktop");
let icon_path = dir.path().join("icons").join("tmuxido.png");
install_desktop_integration_to(&desktop_path, &icon_path).unwrap();
assert!(desktop_path.exists());
}

View File

@ -1,10 +0,0 @@
[Desktop Entry]
Name=Tmuxido
Comment=Quickly find and open projects in tmux
Exec=tmuxido
Icon=tmuxido
Type=Application
Categories=Development;Utility;
Terminal=true
Keywords=tmux;project;fzf;dev;
StartupWMClass=tmuxido

View File

@ -1,218 +0,0 @@
# ============================================================================
# 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",
# "~/work",
# "~/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