Compare commits

..

70 Commits
0.2.1 ... main

Author SHA1 Message Date
d3380c668a feat: fzf README preview with glow support
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
- Show README.md in the right 40% of the fzf picker on hover
- Render with glow (CLICOLOR_FORCE=1 for colors in non-TTY pipe) when available, fall back to cat
- Preview command uses sh -c for fish/zsh/bash compatibility
- Bump version to 0.10.0

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-03-05 20:24:13 -03:00
8aa341080d perf: stale-while-revalidate cache — always instant startup
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
Cache is now returned immediately on every run. When stale (age >
cache_ttl_hours), a detached background process rebuilds it
incrementally via --background-refresh, so blocking scans never
happen in the foreground after the first run.

cache_ttl_hours is now actually enforced (previously ignored).
2026-03-04 23:39:30 -03:00
2abf7e77b4 🐛 fix: run shortcut and desktop wizards after default config too
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
The shortcut/desktop setup was only offered when the user chose the
interactive wizard path; the default config path skipped them entirely.

Move both setup_shortcut_wizard and setup_desktop_integration_wizard
calls out of run_wizard and into ensure_config_exists so they always
run after the config file is written, regardless of the setup mode chosen.
2026-03-01 21:37:14 -03:00
2d7d49d548 🔖 chore: bump version to 0.9.0 and update CHANGELOG
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2026-03-01 20:15:33 -03:00
ee2059986c feat: add first-run setup choice prompt
When no config file exists, ask the user whether to run the
interactive wizard or apply sensible defaults immediately.

- Add `SetupChoice` enum and `parse_setup_choice_input` (pure, tested)
- Add `render_setup_choice_prompt` and `render_default_config_saved` UI helpers
- Extract `Config::write_default_config` and `Config::run_wizard` from
  `ensure_config_exists` for clarity; routing now driven by the user's choice
- 9 new tests covering all branches of `parse_setup_choice_input` and
  the default-config write path
2026-03-01 20:15:30 -03:00
2da5715a34 🔖 chore: bump version to 0.8.3 and commit Cargo.lock
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2026-03-01 20:00:50 -03:00
4263f0379d 🐛 fix: handle space after colon in GitHub API JSON tag_name field
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2026-03-01 19:59:12 -03:00
a8f88e852c 🐛 fix: show GitHub API response when install.sh fails to fetch release
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
Remove -f from curl API call so HTTP errors are visible instead of
silently swallowed; print up to 400 bytes of the raw response to stderr.

Bumps version to 0.8.1.
2026-03-01 19:56:01 -03:00
0e2745acf1 ♻️ refactor: rename --create-desktop-shortcut to --setup-desktop-shortcut
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2026-03-01 19:42:12 -03:00
d7246298b1 feat: add desktop integration wizard and --create-desktop-shortcut
- install_desktop_integration_to() writes .desktop and downloads icon
- setup_desktop_integration_wizard() prompts and installs to XDG paths
- --create-desktop-shortcut flag to re-run at any time
- First-run wizard now also offers desktop integration after shortcut setup
- 164 tests passing
2026-03-01 19:40:15 -03:00
3aacf30697 feat: keyboard shortcut setup wizard (0.8.0)
- Add --setup-shortcut flag to configure a desktop keybinding
- Wizard runs automatically on first run after config creation
- Supports Hyprland (bindings.conf + hyprctl conflict check),
  GNOME (gsettings) and KDE (kglobalshortcutsrc)
- Hyprland: prefers omarchy-launch-tui, falls back to xdg-terminal-exec
- Conflict detection with fallback combo suggestions
- Add tmuxido.desktop and install icon + .desktop in install.sh
- 157 tests passing
2026-03-01 19:22:32 -03:00
906eec994f 📸 decrease canvas size in icon assets
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-01 18:51:31 -03:00
d0f6592729 📷 add tmuxido small icons
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-01 18:41:12 -03:00
3f72128a25 🔖 chore: bump version to 0.7.1 and update CHANGELOG
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-03-01 05:07:06 -03:00
cc16cde540 📝 docs: add ASCII art previews for each available tmux layout in README 2026-03-01 05:07:03 -03:00
db08840b64 🐛 fix: ask for layout in interactive wizard when window has multiple panes
Add `render_layout_prompt` and `parse_layout_input` to ui.rs so that
the first-run wizard asks the user to choose a tmux layout (1–5 or by
name) for each window that has 2 or more panes. Previously, layout was
always silently set to None.

Also update `render_config_created` to display the chosen layout in the
post-setup summary.

Closes: layout never being set during interactive setup
2026-03-01 05:07:00 -03:00
4ecdf96db8 📝 docs: update install URL to GitHub and fix default session description 2026-03-01 05:01:04 -03:00
b05c188477 🐛 fix: consolidate publish-github into single shell block
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is passing
Multiple command blocks caused $RELEASE_ID to be lost between shells.
Using ${ARCH} with braces triggered Drone variable substitution before
the shell ran, resulting in an empty arch name. Fix: single | block
with all logic, iterate over full filenames (same pattern as publish),
and use ENVIRON["DRONE_TAG"] in awk to bypass Drone substitution.
2026-03-01 04:56:36 -03:00
75d66cd47c feat: publish releases to GitHub and update install source
- Self-update now queries the GitHub Releases API (parse_latest_tag extracted for testability)
- install.sh now fetches and downloads from GitHub Releases
- Drone CI release pipeline publishes to both Gitea and GitHub via GITHUB_TOKEN secret
- Bump version to 0.7.0
2026-03-01 04:50:52 -03:00
973042ce7d 🔖 chore: bump version to 0.6.0 and update CHANGELOG
All checks were successful
continuous-integration/drone/tag Build is passing
2026-03-01 04:07:41 -03:00
0f27bedc94 feat: periodic update check on startup
On startup, if `update_check_interval_hours` have elapsed since the last
check, 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 with injected fetcher for full testability
- Cache at ~/.cache/tmuxido/update_check.json tracks timestamp + version
- `fetch_latest_tag` and `version_compare` promoted to `pub(crate)`
- 6 unit tests covering disabled, interval not elapsed, fetch triggered,
  equal versions, update available, and current-newer edge case
2026-03-01 04:07:38 -03:00
da6311bc53 feat: add update_check_interval_hours config field
Adds `update_check_interval_hours: u64` (serde default 24, 0 = disabled)
to Config struct, enabling periodic update check control via config file.
2026-03-01 04:07:31 -03:00
2b1773375a 🐛 fix: add test for asset name format and bump to 0.5.2
All checks were successful
continuous-integration/drone/tag Build is passing
Adds a unit test that asserts detect_arch returns names prefixed
with 'tmuxido-' and suffixed with '-linux', matching what CI uploads.
2026-03-01 03:43:10 -03:00
36aaa65945 📝 docs: add changelog entry for 0.5.1
All checks were successful
continuous-integration/drone/tag Build is passing
2026-03-01 03:38:04 -03:00
10a38a1f85 🔧 ci: delete existing release before recreating on retag
When a tag is deleted and recreated, the CI tried to POST a new
release that already existed, getting 409 and leaving RELEASE_ID
null, which caused asset uploads to fail with 405. Now checks for
an existing release by tag and deletes it before creating a new one.
2026-03-01 03:34:27 -03:00
42bdc1d409 🐛 fix: correct asset name and bump version to 0.5.1
detect_arch was returning "x86_64-linux" but CI uploads assets as
"tmuxido-x86_64-linux", causing 404 on self-update. Also bumps
Cargo.toml to 0.5.1 which was missing from the hotfix tag.
2026-03-01 03:31:10 -03:00
a592c99375 🐛 fix: target tmux windows by name instead of numeric index
Removes base-index detection which was unreliable and defaulted to 0
when tmux's actual base-index was 1, causing "index in use" and
"can't find window" errors on session creation.
2026-03-01 03:17:28 -03:00
ff6050c718 test: add comprehensive tests for interactive configuration wizard
Some checks failed
continuous-integration/drone/tag Build is failing
Add unit tests for the UI parsing functions and configuration logic
to restore test coverage after adding the interactive setup wizard.

- Add parse_max_depth_input, parse_cache_enabled_input, parse_cache_ttl_input
- Add parse_comma_separated_list helper function with tests
- Add tests for all parsing functions covering valid/invalid/empty inputs
- Add tests for color functions and UI render functions
- Add integration test for config with windows and panes
- Refactor config.rs to use shared parsing functions from ui module
2026-03-01 02:35:50 -03:00
15a11ef79c 🔧 chore: update Cargo.lock for version 0.5.0
Add missing Cargo.lock update with lipgloss and its dependencies.
2026-03-01 02:25:01 -03:00
6050cb70f3 🔖 chore: bump version to 0.5.0
Update version in Cargo.toml and add CHANGELOG entry for the new
interactive configuration wizard feature.
2026-03-01 02:23:52 -03:00
61f6a9fee3 feat: add interactive pane and command configuration to setup wizard
Expand the configuration wizard to allow users to define panes within
each window and specify startup commands for each pane. This provides
a complete tmux session setup during initial configuration.

- Add prompts for configuring panes in each window
- Add prompts for startup commands per pane
- Show full window/pane structure in summary
- Display pane commands in the final configuration review
2026-03-01 02:21:12 -03:00
e0da58d114 feat: add interactive setup prompt with lipgloss styling and emojis
Add styled first-time setup UI using lipgloss with Tokyo Night theme
colors. The prompt now includes emojis and better visual feedback when
creating the initial configuration file.

- Add new ui module with styled render functions
- Prompt user for project paths interactively on first run
- Parse comma-separated paths with whitespace trimming
- Show styled success message with configured directories
- Add lipgloss dependency for terminal styling
2026-03-01 02:08:49 -03:00
437584aac7 test: add comprehensive tests for get_projects function
Some checks failed
continuous-integration/drone/tag Build is failing
Add 6 new unit tests covering all execution paths:
- Cache disabled → full scan
- Force refresh → full scan
- No cache → initial scan
- Old cache format → upgrade
- Cache with changes → incremental update
- Cache loaded flow

Refactor get_projects to use dependency injection for testability,
allowing mocks for cache operations and filesystem scanning.

Bump version to 0.4.3
2026-03-01 01:46:22 -03:00
960724685c 📝 docs: update README with improved layout and Rust edition badge
- Move project title below badges for better visual hierarchy
- Update Rust edition badge from 2024 to 2026
- Maintain all existing badges and links
2026-03-01 01:25:24 -03:00
639bcdf643 📚 docs: center badges in README 2026-03-01 01:20:55 -03:00
ddb4b70234 📚 docs: add avatar to author section
Use GitHub avatar image in the author section.
2026-03-01 01:19:45 -03:00
32155bc1d2 📚 docs: add author section to README
Add GitHub profile link and badge for @cinco.
2026-03-01 01:18:49 -03:00
e4cc280f28 🐛 fix: bump version to 0.4.2 to fix self-update version mismatch
Some checks failed
continuous-integration/drone/tag Build is failing
The Cargo.toml was still at 0.4.0 while the release was tagged as 0.4.1,
causing the --update command to always think there's a new version available.

Bumping to 0.4.2 ensures the binary version matches the release tag.
2026-03-01 01:13:21 -03:00
868540b92a force update
Some checks failed
continuous-integration/drone/tag Build is failing
2026-03-01 01:04:15 -03:00
0af46bd6a5 🔧 ci: update release trigger to match tags without v prefix
Some checks failed
continuous-integration/drone/tag Build is failing
2026-03-01 00:58:53 -03:00
ba3f923781 feat: add self-update capability
Add `tmuxido --update` command to update binary from latest release.
- New `self_update` module with version comparison
- Atomic binary replacement with backup/rollback
- Fetches latest release from Gitea API
- Downloads correct binary for system architecture
2026-03-01 00:34:42 -03:00
fcb7c7f6a6 🐛 fix(ci): read DRONE_TAG via awk ENVIRON to fix empty release body
Some checks failed
continuous-integration/drone/tag Build is failing
Drone CI substitutes ${VAR} in commands before the shell runs.
Since TAG is a local shell variable (not a Drone var), ${TAG} was
replaced with an empty string, causing the awk pattern to match
nothing and the release body to be empty.

Fix: read DRONE_TAG directly inside awk via ENVIRON["DRONE_TAG"]
and strip the v-prefix with gsub — no intermediate shell variable needed.

Also translate CHANGELOG.md to English.
2026-03-01 00:19:10 -03:00
3c78a5afe3 🐛 fix(ci): corrigir pipeline release para tags v*
- Filtro de ref corrigido: [0-9]* → v* para bater em tags "v0.3.0"
- awk agora strip o prefixo 'v' de DRONE_TAG antes de buscar no CHANGELOG
- Adiciona entrada 0.3.0 no CHANGELOG.md
2026-03-01 00:01:29 -03:00
4dcac9a6aa feat: verificar dependências fzf e tmux ao iniciar
Adiciona módulo `deps` que, antes de qualquer operação, verifica se
fzf e tmux estão instalados no sistema. Caso faltem, detecta o
gerenciador de pacotes da distro (apt, pacman, dnf, yum, zypper,
emerge, xbps, apk), informa ao usuário e oferece instalar com o
comando adequado.

- `src/deps.rs`: Dep, PackageManager, BinaryChecker (trait injetável),
  check_missing(), detect_package_manager(), ensure_dependencies()
- `src/main.rs`: chama ensure_dependencies() antes do fluxo principal
- `tests/deps.rs`: 11 testes de integração com SystemBinaryChecker real
- `tests/docker/`: Dockerfile multi-stage + suite de 15 testes em
  container Ubuntu 24.04 simulando novo usuário (sem fzf/tmux)
- `.dockerignore`: exclui target/, .git/, .claude/ do contexto Docker
2026-02-28 23:58:09 -03:00
32c40b7226 ci: migrate coverage badge to orphan badges branch
Replace Gitea packages (served as octet-stream) with a dedicated
orphan branch. Raw branch URL serves SVG with correct content-type.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 23:25:10 -03:00
c1adc92c1c fix(ci): delete existing package before PUT to avoid 409
Gitea generic packages returns 409 if file already exists at same
version. Delete the version first, then re-upload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 23:17:56 -03:00
e6180b57c3 ci: migrate coverage badge to Gitea Packages
Replace commit-based badge update with a single PUT to the Gitea
generic packages API. Removes the badges/ directory from the repo
and eliminates CI commits on every push.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 23:16:03 -03:00
90e3b65820 ci: update coverage badge [CI SKIP] 2026-03-01 01:57:30 +00:00
fc2f503886 ci: renomear branch master para main
Atualiza referências de master para main em .drone.yml e README.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 22:56:07 -03:00
b29baee9c4 🎨 docs: adicionar logo ao README 2026-02-28 22:51:48 -03:00
6d842b83ba ci: update coverage badge [CI SKIP] 2026-03-01 01:33:39 +00:00
b4877007f2 🐛 fix(ci): gerar badge SVG inline e remover tr com octais 2026-02-28 22:32:19 -03:00
29ff9535a7 🐛 fix(ci): unificar blocks e sanitizar JSON do Gitea antes do jq 2026-02-28 22:27:08 -03:00
dec566320f ci: add coverage badge [CI SKIP] 2026-03-01 01:22:12 +00:00
6275c638d9 🔖 chore: atualizar Cargo.lock para versão 0.2.4 2026-02-28 22:20:45 -03:00
4d6af13134 🔧 chore: adicionar drone-ci-mcp e corrigir formato do hook PostToolUse 2026-02-28 22:20:42 -03:00
b2a0ee5e08 🐛 fix(ci): usar base64 -w 0 e POST/PUT correto no upload do badge 2026-02-28 22:20:40 -03:00
2bc516ed6d ci: debug base64 and SHA GET response 2026-02-28 21:48:39 -03:00
7e70157097 ci: add debug output to coverage upload step 2026-02-28 21:44:08 -03:00
ef13e884ba ci: use awk to parse coverage percentage 2026-02-28 21:38:28 -03:00
b6e33c37ac ci: fix coverage parsing and JSON payload construction 2026-02-28 21:34:01 -03:00
20f516cffb ci: store coverage badge via repo contents API 2026-02-28 21:29:29 -03:00
a5429c3543 chore: bump version to 0.2.4
Some checks failed
continuous-integration/drone/tag Build is failing
2026-02-28 21:23:31 -03:00
8a7df892e8 ci: fix tarpaulin JSON field for coverage percentage 2026-02-28 21:19:56 -03:00
f0949e08c5 ci: restrict release pipeline to version tags only 2026-02-28 21:14:14 -03:00
d80857090e docs: update CI badge label in README.md
Some checks failed
continuous-integration/drone/tag Build is failing
Changed the CI badge label from "CI" to "Build Status" for better clarity and consistency with common badge naming conventions.
2026-02-28 21:10:45 -03:00
19f36060a0 ci: use Gitea generic package registry for coverage badge 2026-02-28 21:08:39 -03:00
2dc12e37c9 chore: bump version to 0.2.2
Some checks failed
continuous-integration/drone/tag Build is failing
2026-02-28 21:04:39 -03:00
280a180d3e ci: add coverage badge pipeline and README badges 2026-02-28 21:04:05 -03:00
5f281b1f9e docs: add CHANGELOG and use it as release description in CI 2026-02-28 20:26:29 -03:00
31 changed files with 5085 additions and 268 deletions

View File

@ -3,9 +3,13 @@
"PostToolUse": [ "PostToolUse": [
{ {
"matcher": "Write|Edit", "matcher": "Write|Edit",
"type": "command", "hooks": [
"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 "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
}
]
} }
] ]
} }

3
.dockerignore Normal file
View File

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

View File

@ -15,6 +15,35 @@ steps:
- cargo clippy -- -D warnings - cargo clippy -- -D warnings
- cargo test - 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 kind: pipeline
type: docker type: docker
@ -23,6 +52,8 @@ name: release
trigger: trigger:
event: event:
- tag - tag
ref:
- refs/tags/[0-9]*
steps: steps:
- name: build-x86_64 - name: build-x86_64
@ -45,11 +76,29 @@ steps:
commands: commands:
- apk add --no-cache curl jq - 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 \ RELEASE_ID=$(curl -fsSL -X POST \
-H "Authorization: token $GITEA_TOKEN" \ -H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/releases" \ "https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/releases" \
-d "{\"tag_name\":\"$DRONE_TAG\",\"name\":\"$DRONE_TAG\",\"body\":\"Release $DRONE_TAG\"}" \ -d "{\"tag_name\":\"$DRONE_TAG\",\"name\":\"$DRONE_TAG\",\"body\":$(echo "$BODY" | jq -Rs .)}" \
| jq -r .id) | jq -r .id)
for ASSET in tmuxido-x86_64-linux tmuxido-aarch64-linux; do for ASSET in tmuxido-x86_64-linux tmuxido-aarch64-linux; do
curl -fsSL -X POST \ curl -fsSL -X POST \
@ -60,3 +109,45 @@ steps:
depends_on: depends_on:
- build-x86_64 - build-x86_64
- build-aarch64 - 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

View File

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

187
CHANGELOG.md Normal file
View File

@ -0,0 +1,187 @@
# 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

605
Cargo.lock generated
View File

@ -34,35 +34,56 @@ dependencies = [
[[package]] [[package]]
name = "anstyle-query" name = "anstyle-query"
version = "1.1.4" version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
name = "anstyle-wincon" name = "anstyle-wincon"
version = "3.0.10" version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [ dependencies = [
"anstyle", "anstyle",
"once_cell_polyfill", "once_cell_polyfill",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.100" version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "approx"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
dependencies = [
"num-traits",
]
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.10.0" version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "by_address"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
@ -72,9 +93,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.50" version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c2cfd7bf8a6017ddaa4e32ffe7403d547790db06bd171c1c53926faab501623" checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@ -82,9 +103,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.5.50" version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a4c05b9e80c5ccd3a7ef080ad7b6ba7d6fc00a985b8b157197075677c82c7a0" checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
dependencies = [ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
@ -94,9 +115,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.5.49" version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
@ -106,9 +127,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_lex" name = "clap_lex"
version = "0.7.6" version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
@ -116,6 +137,64 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "convert_case"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "crossterm"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"bitflags",
"crossterm_winapi",
"derive_more",
"document-features",
"mio",
"parking_lot",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"rustc_version",
"syn",
]
[[package]] [[package]]
name = "dirs" name = "dirs"
version = "5.0.1" version = "5.0.1"
@ -158,6 +237,15 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@ -174,6 +262,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "fast-srgb8"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1"
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.3.0" version = "2.3.0"
@ -188,9 +282,9 @@ checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.16" version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
@ -199,9 +293,9 @@ dependencies = [
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.4.1" version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
@ -221,9 +315,9 @@ dependencies = [
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.16.0" version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]] [[package]]
name = "heck" name = "heck"
@ -239,12 +333,12 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.12.0" version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown 0.16.0", "hashbrown 0.16.1",
"serde", "serde",
"serde_core", "serde_core",
] ]
@ -257,9 +351,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.15" version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]] [[package]]
name = "leb128fmt" name = "leb128fmt"
@ -269,25 +363,51 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.177" version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
[[package]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.10" version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
dependencies = [ dependencies = [
"bitflags",
"libc", "libc",
] ]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.11.0" version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "lipgloss"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12c1d116ae421d84dfea8bacb5d5fcce330d8b3f03a4867cd1e4860eecd94fb4"
dependencies = [
"crossterm",
"palette",
"strip-ansi-escapes",
"unicode-width",
]
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]] [[package]]
name = "log" name = "log"
@ -297,9 +417,30 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.7.6" version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mio"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
@ -319,6 +460,95 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "palette"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6"
dependencies = [
"approx",
"fast-srgb8",
"palette_derive",
"phf",
]
[[package]]
name = "palette_derive"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30"
dependencies = [
"by_address",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]] [[package]]
name = "prettyplease" name = "prettyplease"
version = "0.2.37" version = "0.2.37"
@ -331,27 +561,51 @@ dependencies = [
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.103" version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.41" version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]] [[package]]
name = "r-efi" name = "r-efi"
version = "5.3.0" version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]] [[package]]
name = "redox_users" name = "redox_users"
@ -359,7 +613,7 @@ version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [ dependencies = [
"getrandom 0.2.16", "getrandom 0.2.17",
"libredox", "libredox",
"thiserror 1.0.69", "thiserror 1.0.69",
] ]
@ -370,16 +624,25 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [ dependencies = [
"getrandom 0.2.16", "getrandom 0.2.17",
"libredox", "libredox",
"thiserror 2.0.17", "thiserror 2.0.18",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
] ]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.3" version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"errno", "errno",
@ -388,12 +651,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]] [[package]]
name = "same-file" name = "same-file"
version = "1.0.6" version = "1.0.6"
@ -403,6 +660,12 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]] [[package]]
name = "semver" name = "semver"
version = "1.0.27" version = "1.0.27"
@ -441,15 +704,15 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.145" version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr", "memchr",
"ryu",
"serde", "serde",
"serde_core", "serde_core",
"zmij",
] ]
[[package]] [[package]]
@ -463,13 +726,65 @@ dependencies = [
[[package]] [[package]]
name = "shellexpand" name = "shellexpand"
version = "3.1.1" version = "3.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8"
dependencies = [ dependencies = [
"dirs 6.0.0", "dirs 6.0.0",
] ]
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "siphasher"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "strip-ansi-escapes"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025"
dependencies = [
"vte",
]
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.1" version = "0.11.1"
@ -478,9 +793,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.108" version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -489,12 +804,12 @@ dependencies = [
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.25.0" version = "3.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom 0.4.1", "getrandom 0.4.2",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@ -511,11 +826,11 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.17" version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [ dependencies = [
"thiserror-impl 2.0.17", "thiserror-impl 2.0.18",
] ]
[[package]] [[package]]
@ -531,9 +846,9 @@ dependencies = [
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "2.0.17" version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -542,11 +857,12 @@ dependencies = [
[[package]] [[package]]
name = "tmuxido" name = "tmuxido"
version = "0.2.0" version = "0.10.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
"dirs 5.0.1", "dirs 5.0.1",
"lipgloss",
"serde", "serde",
"serde_json", "serde_json",
"shellexpand", "shellexpand",
@ -598,9 +914,21 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.20" version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]] [[package]]
name = "unicode-xid" name = "unicode-xid"
@ -614,6 +942,15 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "vte"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "walkdir" name = "walkdir"
version = "2.5.0" version = "2.5.0"
@ -682,6 +1019,22 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]] [[package]]
name = "winapi-util" name = "winapi-util"
version = "0.1.11" version = "0.1.11"
@ -691,6 +1044,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
@ -703,16 +1062,7 @@ version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [ dependencies = [
"windows-targets 0.48.5", "windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
] ]
[[package]] [[package]]
@ -730,30 +1080,13 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [ dependencies = [
"windows_aarch64_gnullvm 0.48.5", "windows_aarch64_gnullvm",
"windows_aarch64_msvc 0.48.5", "windows_aarch64_msvc",
"windows_i686_gnu 0.48.5", "windows_i686_gnu",
"windows_i686_msvc 0.48.5", "windows_i686_msvc",
"windows_x86_64_gnu 0.48.5", "windows_x86_64_gnu",
"windows_x86_64_gnullvm 0.48.5", "windows_x86_64_gnullvm",
"windows_x86_64_msvc 0.48.5", "windows_x86_64_msvc",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
] ]
[[package]] [[package]]
@ -762,95 +1095,47 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.48.5" version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.48.5" version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.48.5" version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.48.5" version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.48.5" version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.48.5" version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "0.7.13" version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
@ -942,3 +1227,9 @@ dependencies = [
"unicode-xid", "unicode-xid",
"wasmparser", "wasmparser",
] ]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View File

@ -1,6 +1,6 @@
[package] [package]
name = "tmuxido" name = "tmuxido"
version = "0.2.1" version = "0.10.0"
edition = "2024" edition = "2024"
[dev-dependencies] [dev-dependencies]
@ -15,3 +15,4 @@ walkdir = "2.4"
anyhow = "1.0" anyhow = "1.0"
shellexpand = "3.1" shellexpand = "3.1"
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
lipgloss = "0.1"

109
README.md
View File

@ -1,3 +1,15 @@
<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 # tmuxido
A Rust-based tool to quickly find and open projects in tmux using fzf. No external dependencies except tmux and fzf! A Rust-based tool to quickly find and open projects in tmux using fzf. No external dependencies except tmux and fzf!
@ -5,19 +17,21 @@ A Rust-based tool to quickly find and open projects in tmux using fzf. No extern
## Features ## Features
- Search for git repositories in configurable paths - Search for git repositories in configurable paths
- Interactive selection using fzf - Interactive selection using fzf with live `README.md` preview (rendered via `glow` when available)
- Native tmux session creation (no tmuxinator required!) - Native tmux session creation (no tmuxinator required!)
- Support for project-specific `.tmuxido.toml` configs - Support for project-specific `.tmuxido.toml` configs
- Smart session switching (reuses existing sessions) - Smart session switching (reuses existing sessions)
- TOML-based configuration - TOML-based configuration
- Smart caching system for fast subsequent runs - Smart caching system for fast subsequent runs
- Configurable cache TTL - 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) - Zero external dependencies (except tmux and fzf)
## Installation ## Installation
```sh ```sh
curl -fsSL https://git.cincoeuzebio.com/cinco/Tmuxido/raw/branch/main/install.sh | 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`. Installs the latest release binary to `~/.local/bin/tmuxido`. On first run, the config file is created automatically at `~/.config/tmuxido/tmuxido.toml`.
@ -88,6 +102,23 @@ Check cache status:
tmuxido --cache-status 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: View help:
```bash ```bash
tmuxido --help tmuxido --help
@ -105,7 +136,7 @@ tmuxido --help
3. Presents them using fzf for selection 3. Presents them using fzf for selection
4. Creates or switches to a tmux session for the selected project 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 5. If a `.tmuxido.toml` config exists in the project, uses it to set up custom windows and panes
6. Otherwise, creates a default session with two windows: "editor" and "terminal" 6. Otherwise, uses the default session config from `~/.config/tmuxido/tmuxido.toml` (configured interactively on first run)
## Caching ## Caching
@ -140,11 +171,61 @@ panes = []
### Available Layouts ### Available Layouts
- `main-horizontal` - Main pane on top, others below **`main-horizontal`** — Main pane on top, others below
- `main-vertical` - Main pane on left, others on right
- `tiled` - All panes tiled ```
- `even-horizontal` - All panes in horizontal row ┌──────────────────────┐
- `even-vertical` - All panes in vertical column │ │
│ 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 ### Panes
@ -152,3 +233,15 @@ Each window can have multiple panes with commands that run automatically:
- First pane is the main window pane - First pane is the main window pane
- Additional panes are created by splitting - Additional panes are created by splitting
- Empty panes array = just open the window in the project directory - 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@ -1,9 +1,13 @@
#!/bin/sh #!/bin/sh
set -e set -e
REPO="cinco/Tmuxido" REPO="cinco/tmuxido"
BASE_URL="https://git.cincoeuzebio.com" 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" INSTALL_DIR="$HOME/.local/bin"
ICON_DIR="$HOME/.local/share/icons/hicolor/96x96/apps"
DESKTOP_DIR="$HOME/.local/share/applications"
arch=$(uname -m) arch=$(uname -m)
case "$arch" in case "$arch" in
@ -12,18 +16,48 @@ case "$arch" in
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; *) echo "Unsupported architecture: $arch" >&2; exit 1 ;;
esac esac
tag=$(curl -fsSL "$BASE_URL/api/v1/repos/$REPO/releases?limit=1&page=1" \ api_resp=$(curl -sSL \
| grep -o '"tag_name":"[^"]*"' | head -1 | cut -d'"' -f4) -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 '"')
[ -z "$tag" ] && { echo "Could not fetch latest release" >&2; exit 1; } 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..." echo "Installing tmuxido $tag..."
# Binary
mkdir -p "$INSTALL_DIR" mkdir -p "$INSTALL_DIR"
curl -fsSL "$BASE_URL/$REPO/releases/download/$tag/$file" -o "$INSTALL_DIR/tmuxido" curl -fsSL "$BASE_URL/$REPO/releases/download/$tag/$file" -o "$INSTALL_DIR/tmuxido"
chmod +x "$INSTALL_DIR/tmuxido" chmod +x "$INSTALL_DIR/tmuxido"
echo "Installed: $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 case ":$PATH:" in
*":$INSTALL_DIR:"*) ;; *":$INSTALL_DIR:"*) ;;
*) echo "Note: add $INSTALL_DIR to your PATH (e.g. export PATH=\"\$HOME/.local/bin:\$PATH\")" ;; *) echo "Note: add $INSTALL_DIR to your PATH (e.g. export PATH=\"\$HOME/.local/bin:\$PATH\")" ;;
esac esac
echo "Done! Run 'tmuxido' to get started."

View File

@ -4,6 +4,7 @@ use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use crate::session::SessionConfig; use crate::session::SessionConfig;
use crate::ui;
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
pub struct Config { pub struct Config {
@ -14,6 +15,8 @@ pub struct Config {
pub cache_enabled: bool, pub cache_enabled: bool,
#[serde(default = "default_cache_ttl_hours")] #[serde(default = "default_cache_ttl_hours")]
pub cache_ttl_hours: u64, pub cache_ttl_hours: u64,
#[serde(default = "default_update_check_interval_hours")]
pub update_check_interval_hours: u64,
#[serde(default = "default_session_config")] #[serde(default = "default_session_config")]
pub default_session: SessionConfig, pub default_session: SessionConfig,
} }
@ -30,6 +33,10 @@ fn default_cache_ttl_hours() -> u64 {
24 24
} }
fn default_update_check_interval_hours() -> u64 {
24
}
fn default_session_config() -> SessionConfig { fn default_session_config() -> SessionConfig {
use crate::session::Window; use crate::session::Window;
@ -89,20 +96,167 @@ impl Config {
) )
})?; })?;
let default_config = Self::default_config(); // Ask whether to run the interactive wizard or apply sensible defaults
let toml_string = toml::to_string_pretty(&default_config) let raw = ui::render_setup_choice_prompt()?;
.context("Failed to serialize default config")?; 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)?;
}
}
fs::write(&config_path, toml_string).with_context(|| { // Offer shortcut and desktop integration regardless of setup mode
format!("Failed to write config file: {}", config_path.display()) if let Err(e) = crate::shortcut::setup_shortcut_wizard() {
})?; eprintln!("Warning: shortcut setup failed: {}", e);
}
eprintln!("Created default config at: {}", config_path.display()); if let Err(e) = crate::shortcut::setup_desktop_integration_wizard() {
eprintln!("Warning: desktop integration failed: {}", e);
}
} }
Ok(config_path) 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 { fn default_config() -> Self {
Config { Config {
paths: vec![ paths: vec![
@ -115,6 +269,7 @@ impl Config {
max_depth: 5, max_depth: 5,
cache_enabled: true, cache_enabled: true,
cache_ttl_hours: 24, cache_ttl_hours: 24,
update_check_interval_hours: default_update_check_interval_hours(),
default_session: default_session_config(), default_session: default_session_config(),
} }
} }
@ -131,6 +286,7 @@ mod tests {
assert_eq!(config.max_depth, 5); assert_eq!(config.max_depth, 5);
assert!(config.cache_enabled); assert!(config.cache_enabled);
assert_eq!(config.cache_ttl_hours, 24); assert_eq!(config.cache_ttl_hours, 24);
assert_eq!(config.update_check_interval_hours, 24);
} }
#[test] #[test]
@ -153,4 +309,149 @@ mod tests {
let result: Result<Config, _> = toml::from_str("not valid toml ]][["); let result: Result<Config, _> = toml::from_str("not valid toml ]][[");
assert!(result.is_err()); 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());
}
} }

425
src/deps.rs Normal file
View File

@ -0,0 +1,425 @@
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,6 +1,11 @@
pub mod cache; pub mod cache;
pub mod config; pub mod config;
pub mod deps;
pub mod self_update;
pub mod session; pub mod session;
pub mod shortcut;
pub mod ui;
pub mod update_check;
use anyhow::Result; use anyhow::Result;
use cache::ProjectCache; use cache::ProjectCache;
@ -11,6 +16,14 @@ use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH; use std::time::UNIX_EPOCH;
use walkdir::WalkDir; 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<()> { pub fn show_cache_status(config: &Config) -> Result<()> {
if !config.cache_enabled { if !config.cache_enabled {
println!("Cache is disabled in configuration"); println!("Cache is disabled in configuration");
@ -36,44 +49,85 @@ pub fn show_cache_status(config: &Config) -> Result<()> {
} }
pub fn get_projects(config: &Config, force_refresh: bool) -> Result<Vec<PathBuf>> { 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 { if !config.cache_enabled || force_refresh {
let (projects, fingerprints) = scan_all_roots(config)?; let (projects, fingerprints) = scanner(config)?;
let cache = ProjectCache::new(projects.clone(), fingerprints); let cache = ProjectCache::new(projects.clone(), fingerprints);
cache.save()?; cache_saver(&cache)?;
eprintln!("Cache updated with {} projects", projects.len());
return Ok(projects); return Ok(projects);
} }
if let Some(mut cache) = ProjectCache::load()? { if let Some(cache) = cache_loader()? {
// Cache no formato antigo (sem dir_mtimes) → atualizar com rescan completo // Cache exists — return immediately (stale-while-revalidate).
if cache.dir_mtimes.is_empty() { // Spawn a background refresh if the cache is stale or in old format.
eprintln!("Upgrading cache, scanning for projects..."); let is_stale =
let (projects, fingerprints) = scan_all_roots(config)?; cache.dir_mtimes.is_empty() || cache.age_in_seconds() > config.cache_ttl_hours * 3600;
let new_cache = ProjectCache::new(projects.clone(), fingerprints); if is_stale {
new_cache.save()?; refresh_spawner();
eprintln!("Cache updated with {} projects", projects.len());
return Ok(projects);
}
let changed = cache.validate_and_update(&|root| scan_from_root(root, config))?;
if changed {
cache.save()?;
eprintln!(
"Cache updated incrementally ({} projects)",
cache.projects.len()
);
} else {
eprintln!("Using cached projects ({} projects)", cache.projects.len());
} }
return Ok(cache.projects); return Ok(cache.projects);
} }
// Sem cache ainda — scan completo inicial // No cache yet — first run, blocking scan is unavoidable.
eprintln!("No cache found, scanning for projects..."); eprintln!("No cache found, scanning for projects...");
let (projects, fingerprints) = scan_all_roots(config)?; let (projects, fingerprints) = scanner(config)?;
let cache = ProjectCache::new(projects.clone(), fingerprints); let cache = ProjectCache::new(projects.clone(), fingerprints);
cache.save()?; cache_saver(&cache)?;
eprintln!("Cache updated with {} projects", projects.len());
Ok(projects) Ok(projects)
} }
@ -160,3 +214,214 @@ pub fn launch_tmux_session(selected: &Path, config: &Config) -> Result<()> {
Ok(()) 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

@ -4,7 +4,13 @@ use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use tmuxido::config::Config; use tmuxido::config::Config;
use tmuxido::{get_projects, launch_tmux_session, show_cache_status}; 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)] #[derive(Parser, Debug)]
#[command( #[command(
@ -23,17 +29,61 @@ struct Args {
/// Show cache status and exit /// Show cache status and exit
#[arg(long)] #[arg(long)]
cache_status: bool, 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<()> { fn main() -> Result<()> {
let args = Args::parse(); 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 // Ensure config exists
Config::ensure_config_exists()?; Config::ensure_config_exists()?;
// Load config // Load config
let config = Config::load()?; let config = Config::load()?;
// Periodic update check (silent on failure or no update)
update_check::check_and_notify(&config);
// Handle cache status command // Handle cache status command
if args.cache_status { if args.cache_status {
show_cache_status(&config)?; show_cache_status(&config)?;
@ -66,8 +116,34 @@ fn main() -> Result<()> {
Ok(()) 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> { fn select_project_with_fzf(projects: &[PathBuf]) -> Result<PathBuf> {
let preview_cmd = readme_preview_command();
let mut child = Command::new("fzf") let mut child = Command::new("fzf")
.arg("--preview")
.arg(&preview_cmd)
.arg("--preview-window")
.arg("right:40%")
.stdin(Stdio::piped()) .stdin(Stdio::piped())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.spawn() .spawn()

254
src/self_update.rs Normal file
View File

@ -0,0 +1,254 @@
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

@ -41,7 +41,6 @@ impl SessionConfig {
pub struct TmuxSession { pub struct TmuxSession {
pub(crate) session_name: String, pub(crate) session_name: String,
project_path: String, project_path: String,
base_index: usize,
} }
impl TmuxSession { impl TmuxSession {
@ -53,34 +52,12 @@ impl TmuxSession {
.replace('.', "_") .replace('.', "_")
.replace(' ', "-"); .replace(' ', "-");
let base_index = Self::get_base_index();
Self { Self {
session_name, session_name,
project_path: project_path.display().to_string(), project_path: project_path.display().to_string(),
base_index,
} }
} }
fn get_base_index() -> usize {
// Try to get base-index from tmux
let output = Command::new("tmux")
.args(["show-options", "-gv", "base-index"])
.output();
if let Ok(output) = output
&& output.status.success()
{
let index_str = String::from_utf8_lossy(&output.stdout);
if let Ok(index) = index_str.trim().parse::<usize>() {
return index;
}
}
// Default to 0 if we can't determine
0
}
pub fn create(&self, config: &SessionConfig) -> Result<()> { pub fn create(&self, config: &SessionConfig) -> Result<()> {
// Check if we're already inside a tmux session // Check if we're already inside a tmux session
let inside_tmux = std::env::var("TMUX").is_ok(); let inside_tmux = std::env::var("TMUX").is_ok();
@ -167,25 +144,23 @@ impl TmuxSession {
.status() .status()
.context("Failed to create tmux session")?; .context("Failed to create tmux session")?;
// Create panes for first window if specified let first_target = format!("{}:{}", self.session_name, first_window.name);
if !first_window.panes.is_empty() { if !first_window.panes.is_empty() {
self.create_panes(self.base_index, &first_window.panes)?; self.create_panes(&first_target, &first_window.panes)?;
} }
// Apply layout for first window if specified
if let Some(layout) = &first_window.layout { if let Some(layout) = &first_window.layout {
self.apply_layout(self.base_index, layout)?; self.apply_layout(&first_target, layout)?;
} }
// Create additional windows // Create additional windows, targeting by session name so tmux auto-assigns the index
for (index, window) in config.windows.iter().skip(1).enumerate() { for window in config.windows.iter().skip(1) {
let window_index = self.base_index + index + 1;
Command::new("tmux") Command::new("tmux")
.args([ .args([
"new-window", "new-window",
"-t", "-t",
&format!("{}:{}", self.session_name, window_index), &self.session_name,
"-n", "-n",
&window.name, &window.name,
"-c", "-c",
@ -194,46 +169,44 @@ impl TmuxSession {
.status() .status()
.with_context(|| format!("Failed to create window: {}", window.name))?; .with_context(|| format!("Failed to create window: {}", window.name))?;
// Create panes if specified let target = format!("{}:{}", self.session_name, window.name);
if !window.panes.is_empty() { if !window.panes.is_empty() {
self.create_panes(window_index, &window.panes)?; self.create_panes(&target, &window.panes)?;
} }
// Apply layout if specified
if let Some(layout) = &window.layout { if let Some(layout) = &window.layout {
self.apply_layout(window_index, layout)?; self.apply_layout(&target, layout)?;
} }
} }
// Select the first window // Select the first window by name
Command::new("tmux") Command::new("tmux")
.args([ .args(["select-window", "-t", &first_target])
"select-window",
"-t",
&format!("{}:{}", self.session_name, self.base_index),
])
.status() .status()
.context("Failed to select first window")?; .context("Failed to select first window")?;
Ok(()) Ok(())
} }
fn create_panes(&self, window_index: usize, panes: &[String]) -> Result<()> { fn create_panes(&self, window_target: &str, panes: &[String]) -> Result<()> {
for (pane_index, command) in panes.iter().enumerate() { for (pane_index, command) in panes.iter().enumerate() {
let target = format!("{}:{}", self.session_name, window_index);
// First pane already exists (created with the window), skip split // First pane already exists (created with the window), skip split
if pane_index > 0 { if pane_index > 0 {
// Create new pane by splitting
Command::new("tmux") Command::new("tmux")
.args(["split-window", "-t", &target, "-c", &self.project_path]) .args([
"split-window",
"-t",
window_target,
"-c",
&self.project_path,
])
.status() .status()
.context("Failed to split pane")?; .context("Failed to split pane")?;
} }
// Send the command to the pane if it's not empty
if !command.is_empty() { if !command.is_empty() {
let pane_target = format!("{}:{}.{}", self.session_name, window_index, pane_index); let pane_target = format!("{}.{}", window_target, pane_index);
Command::new("tmux") Command::new("tmux")
.args(["send-keys", "-t", &pane_target, command, "Enter"]) .args(["send-keys", "-t", &pane_target, command, "Enter"])
.status() .status()
@ -244,14 +217,9 @@ impl TmuxSession {
Ok(()) Ok(())
} }
fn apply_layout(&self, window_index: usize, layout: &str) -> Result<()> { fn apply_layout(&self, window_target: &str, layout: &str) -> Result<()> {
Command::new("tmux") Command::new("tmux")
.args([ .args(["select-layout", "-t", window_target, layout])
"select-layout",
"-t",
&format!("{}:{}", self.session_name, window_index),
layout,
])
.status() .status()
.with_context(|| format!("Failed to apply layout: {}", layout))?; .with_context(|| format!("Failed to apply layout: {}", layout))?;

984
src/shortcut.rs Normal file
View File

@ -0,0 +1,984 @@
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 Normal file

File diff suppressed because it is too large Load Diff

219
src/update_check.rs Normal file
View File

@ -0,0 +1,219 @@
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);
}
}

137
tests/deps.rs Normal file
View File

@ -0,0 +1,137 @@
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()
);
}
}
}

43
tests/docker/Dockerfile Normal file
View File

@ -0,0 +1,43 @@
# 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"]

185
tests/docker/entrypoint.sh Executable file
View File

@ -0,0 +1,185 @@
#!/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 ]

54
tests/docker/run.sh Executable file
View File

@ -0,0 +1,54 @@
#!/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

@ -10,6 +10,7 @@ fn make_config(max_depth: usize) -> Config {
max_depth, max_depth,
cache_enabled: true, cache_enabled: true,
cache_ttl_hours: 24, cache_ttl_hours: 24,
update_check_interval_hours: 24,
default_session: SessionConfig { windows: vec![] }, default_session: SessionConfig { windows: vec![] },
} }
} }

165
tests/shortcut.rs Normal file
View File

@ -0,0 +1,165 @@
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());
}

10
tmuxido.desktop Normal file
View File

@ -0,0 +1,10 @@
[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