Compare commits

...

39 Commits
0.4.0 ... 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
22 changed files with 3892 additions and 287 deletions

View File

@ -76,6 +76,16 @@ steps:
commands:
- apk add --no-cache curl jq
- |
# Delete existing release for this tag if present (handles retag scenarios)
EXISTING_ID=$(curl -fsSL \
-H "Authorization: token $GITEA_TOKEN" \
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/releases/tags/$DRONE_TAG" \
| jq -r '.id // empty')
if [ -n "$EXISTING_ID" ]; then
curl -fsSL -X DELETE \
-H "Authorization: token $GITEA_TOKEN" \
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/releases/$EXISTING_ID"
fi
# Read DRONE_TAG via ENVIRON inside awk to avoid Drone's ${VAR} substitution
# which would replace ${TAG} with an empty string before the shell runs.
BODY=$(awk '
@ -99,3 +109,45 @@ steps:
depends_on:
- build-x86_64
- build-aarch64
- name: publish-github
image: alpine
environment:
GITHUB_TOKEN:
from_secret: GITHUB_TOKEN
GITHUB_REPO: cinco/tmuxido
commands:
- apk add --no-cache curl jq
- |
EXISTING=$(curl -fsSL \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/$GITHUB_REPO/releases/tags/$DRONE_TAG" | jq -r '.id // empty')
if [ -n "$EXISTING" ]; then
curl -s -X DELETE \
-H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/repos/$GITHUB_REPO/releases/$EXISTING"
fi
BODY=$(awk '
BEGIN { tag = ENVIRON["DRONE_TAG"] }
/^## \[/ { in_section = (index($0, "[" tag "]") > 0); next }
in_section && /^## \[/ { exit }
in_section { print }
' CHANGELOG.md)
RELEASE_ID=$(curl -fsSL -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
-H "Content-Type: application/json" \
"https://api.github.com/repos/$GITHUB_REPO/releases" \
-d "{\"tag_name\":\"$DRONE_TAG\",\"name\":\"$DRONE_TAG\",\"body\":$(echo "$BODY" | jq -Rs .)}" \
| jq -r '.id')
for FILE in tmuxido-x86_64-linux tmuxido-aarch64-linux; do
curl -fsSL -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Content-Type: application/octet-stream" \
"https://uploads.github.com/repos/$GITHUB_REPO/releases/$RELEASE_ID/assets?name=$FILE" \
--data-binary @"$FILE"
done
depends_on:
- build-x86_64
- build-aarch64

View File

@ -4,6 +4,129 @@ 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
@ -61,3 +184,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Added
- Initial release of tmuxido

605
Cargo.lock generated
View File

@ -34,35 +34,56 @@ dependencies = [
[[package]]
name = "anstyle-query"
version = "1.1.4"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.10"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.100"
version = "1.0.102"
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]]
name = "bitflags"
version = "2.10.0"
version = "2.11.0"
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]]
name = "cfg-if"
@ -72,9 +93,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clap"
version = "4.5.50"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c2cfd7bf8a6017ddaa4e32ffe7403d547790db06bd171c1c53926faab501623"
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
dependencies = [
"clap_builder",
"clap_derive",
@ -82,9 +103,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.50"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a4c05b9e80c5ccd3a7ef080ad7b6ba7d6fc00a985b8b157197075677c82c7a0"
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
dependencies = [
"anstream",
"anstyle",
@ -94,9 +115,9 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.5.49"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck",
"proc-macro2",
@ -106,9 +127,9 @@ dependencies = [
[[package]]
name = "clap_lex"
version = "0.7.6"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "colorchoice"
@ -116,6 +137,64 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "dirs"
version = "5.0.1"
@ -158,6 +237,15 @@ dependencies = [
"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]]
name = "equivalent"
version = "1.0.2"
@ -174,6 +262,12 @@ dependencies = [
"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]]
name = "fastrand"
version = "2.3.0"
@ -188,9 +282,9 @@ checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "getrandom"
version = "0.2.16"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
@ -199,9 +293,9 @@ dependencies = [
[[package]]
name = "getrandom"
version = "0.4.1"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
@ -221,9 +315,9 @@ dependencies = [
[[package]]
name = "hashbrown"
version = "0.16.0"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heck"
@ -239,12 +333,12 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.12.0"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown 0.16.0",
"hashbrown 0.16.1",
"serde",
"serde_core",
]
@ -257,9 +351,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.15"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "leb128fmt"
@ -269,25 +363,51 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.177"
version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
[[package]]
name = "libredox"
version = "0.1.10"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
dependencies = [
"bitflags",
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
version = "0.12.1"
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]]
name = "log"
@ -297,9 +417,30 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "memchr"
version = "2.7.6"
version = "2.8.0"
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]]
name = "once_cell"
@ -319,6 +460,95 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "prettyplease"
version = "0.2.37"
@ -331,27 +561,51 @@ dependencies = [
[[package]]
name = "proc-macro2"
version = "1.0.103"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.41"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
version = "6.0.0"
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]]
name = "redox_users"
@ -359,7 +613,7 @@ version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.16",
"getrandom 0.2.17",
"libredox",
"thiserror 1.0.69",
]
@ -370,16 +624,25 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.16",
"getrandom 0.2.17",
"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]]
name = "rustix"
version = "1.1.3"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
@ -388,12 +651,6 @@ dependencies = [
"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]]
name = "same-file"
version = "1.0.6"
@ -403,6 +660,12 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.27"
@ -441,15 +704,15 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.145"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
"zmij",
]
[[package]]
@ -463,13 +726,65 @@ dependencies = [
[[package]]
name = "shellexpand"
version = "3.1.1"
version = "3.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb"
checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8"
dependencies = [
"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]]
name = "strsim"
version = "0.11.1"
@ -478,9 +793,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.108"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
@ -489,12 +804,12 @@ dependencies = [
[[package]]
name = "tempfile"
version = "3.25.0"
version = "3.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
dependencies = [
"fastrand",
"getrandom 0.4.1",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@ -511,11 +826,11 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.17"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl 2.0.17",
"thiserror-impl 2.0.18",
]
[[package]]
@ -531,9 +846,9 @@ dependencies = [
[[package]]
name = "thiserror-impl"
version = "2.0.17"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
@ -542,11 +857,12 @@ dependencies = [
[[package]]
name = "tmuxido"
version = "0.4.0"
version = "0.10.0"
dependencies = [
"anyhow",
"clap",
"dirs 5.0.1",
"lipgloss",
"serde",
"serde_json",
"shellexpand",
@ -598,9 +914,21 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "unicode-ident"
version = "1.0.20"
version = "1.0.24"
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]]
name = "unicode-xid"
@ -614,6 +942,15 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "vte"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077"
dependencies = [
"memchr",
]
[[package]]
name = "walkdir"
version = "2.5.0"
@ -682,6 +1019,22 @@ dependencies = [
"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]]
name = "winapi-util"
version = "0.1.11"
@ -691,6 +1044,12 @@ dependencies = [
"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]]
name = "windows-link"
version = "0.2.1"
@ -703,16 +1062,7 @@ version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
"windows-targets",
]
[[package]]
@ -730,30 +1080,13 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
@ -762,95 +1095,47 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.13"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
@ -942,3 +1227,9 @@ dependencies = [
"unicode-xid",
"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]
name = "tmuxido"
version = "0.4.0"
version = "0.10.0"
edition = "2024"
[dev-dependencies]
@ -15,3 +15,4 @@ walkdir = "2.4"
anyhow = "1.0"
shellexpand = "3.1"
clap = { version = "4.5", features = ["derive"] }
lipgloss = "0.1"

100
README.md
View File

@ -1,20 +1,23 @@
<div align="center">
<img src="docs/assets/tmuxido-logo.png" alt="tmuxido logo" width="200"/>
</div>
# tmuxido
<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 2024](https://img.shields.io/badge/rust-edition_2024-orange?logo=rust)
![Rust 2026](https://img.shields.io/badge/rust-edition_2026-orange?logo=rust)
</div>
# tmuxido
A Rust-based tool to quickly find and open projects in tmux using fzf. No external dependencies except tmux and fzf!
## Features
- Search for git repositories in configurable paths
- Interactive selection using fzf
- Interactive selection using fzf with live `README.md` preview (rendered via `glow` when available)
- Native tmux session creation (no tmuxinator required!)
- Support for project-specific `.tmuxido.toml` configs
- Smart session switching (reuses existing sessions)
@ -22,12 +25,13 @@ A Rust-based tool to quickly find and open projects in tmux using fzf. No extern
- Smart caching system for fast subsequent runs
- Configurable cache TTL
- Self-update capability (`tmuxido --update`)
- Keyboard shortcut setup for Hyprland, GNOME, and KDE (`tmuxido --setup-shortcut`)
- Zero external dependencies (except tmux and fzf)
## Installation
```sh
curl -fsSL https://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`.
@ -103,6 +107,18 @@ Update tmuxido to the latest version:
tmuxido --update
```
Set up a desktop keyboard shortcut (Hyprland, GNOME, KDE):
```bash
tmuxido --setup-shortcut
```
Install the `.desktop` entry and icon (so tmuxido appears in app launchers like Walker/Rofi):
```bash
tmuxido --setup-desktop-shortcut
```
Both are also offered automatically on first run. Re-run them any time to reconfigure.
View help:
```bash
tmuxido --help
@ -120,7 +136,7 @@ tmuxido --help
3. Presents them using fzf for selection
4. Creates or switches to a tmux session for the selected project
5. If a `.tmuxido.toml` config exists in the project, uses it to set up custom windows and panes
6. Otherwise, creates a default session with two windows: "editor" and "terminal"
6. Otherwise, uses the default session config from `~/.config/tmuxido/tmuxido.toml` (configured interactively on first run)
## Caching
@ -155,11 +171,61 @@ panes = []
### Available Layouts
- `main-horizontal` - Main pane on top, others below
- `main-vertical` - Main pane on left, others on right
- `tiled` - All panes tiled
- `even-horizontal` - All panes in horizontal row
- `even-vertical` - All panes in vertical column
**`main-horizontal`** — Main pane on top, others below
```
┌──────────────────────┐
│ │
│ main pane │
│ │
├──────────┬───────────┤
│ pane 2 │ pane 3 │
└──────────┴───────────┘
```
**`main-vertical`** — Main pane on left, others on right
```
┌─────────────┬────────┐
│ │ pane 2 │
│ main pane ├────────┤
│ │ pane 3 │
│ ├────────┤
│ │ pane 4 │
└─────────────┴────────┘
```
**`tiled`** — All panes tiled equally
```
┌───────────┬──────────┐
│ pane 1 │ pane 2 │
├───────────┼──────────┤
│ pane 3 │ pane 4 │
└───────────┴──────────┘
```
**`even-horizontal`** — All panes side by side
```
┌────────┬────────┬────────┐
│ │ │ │
│ pane 1 │ pane 2 │ pane 3 │
│ │ │ │
└────────┴────────┴────────┘
```
**`even-vertical`** — All panes stacked
```
┌──────────────────────┐
│ pane 1 │
├──────────────────────┤
│ pane 2 │
├──────────────────────┤
│ pane 3 │
└──────────────────────┘
```
### Panes
@ -167,3 +233,15 @@ Each window can have multiple panes with commands that run automatically:
- First pane is the main window pane
- Additional panes are created by splitting
- Empty panes array = just open the window in the project directory
## Author
<div align="center">
<a href="https://github.com/cinco">
<img src="https://github.com/cinco.png" width="100" height="100" style="border-radius: 50%;" alt="Cinco avatar"/>
</a>
<br><br>
<strong>Cinco</strong>
<br>
<a href="https://github.com/cinco">@cinco</a>
</div>

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

View File

@ -1,9 +1,13 @@
#!/bin/sh
set -e
REPO="cinco/Tmuxido"
BASE_URL="https://git.cincoeuzebio.com"
REPO="cinco/tmuxido"
BASE_URL="https://github.com"
RAW_URL="https://raw.githubusercontent.com/$REPO/refs/heads/main"
API_URL="https://api.github.com"
INSTALL_DIR="$HOME/.local/bin"
ICON_DIR="$HOME/.local/share/icons/hicolor/96x96/apps"
DESKTOP_DIR="$HOME/.local/share/applications"
arch=$(uname -m)
case "$arch" in
@ -12,18 +16,48 @@ case "$arch" in
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;;
esac
tag=$(curl -fsSL "$BASE_URL/api/v1/repos/$REPO/releases?limit=1&page=1" \
| grep -o '"tag_name":"[^"]*"' | head -1 | cut -d'"' -f4)
api_resp=$(curl -sSL \
-H "Accept: application/vnd.github.v3+json" \
"$API_URL/repos/$REPO/releases/latest")
tag=$(printf '%s' "$api_resp" | grep -o '"tag_name": *"[^"]*"' | grep -o '"[^"]*"$' | tr -d '"')
[ -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..."
# Binary
mkdir -p "$INSTALL_DIR"
curl -fsSL "$BASE_URL/$REPO/releases/download/$tag/$file" -o "$INSTALL_DIR/tmuxido"
chmod +x "$INSTALL_DIR/tmuxido"
echo "Installed: $INSTALL_DIR/tmuxido"
echo " binary → $INSTALL_DIR/tmuxido"
# Icon (96×96)
mkdir -p "$ICON_DIR"
curl -fsSL "$RAW_URL/docs/assets/tmuxido-icon_96.png" -o "$ICON_DIR/tmuxido.png"
echo " icon → $ICON_DIR/tmuxido.png"
# .desktop entry
mkdir -p "$DESKTOP_DIR"
curl -fsSL "$RAW_URL/tmuxido.desktop" -o "$DESKTOP_DIR/tmuxido.desktop"
echo " desktop → $DESKTOP_DIR/tmuxido.desktop"
# Refresh desktop database if available
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true
fi
# Refresh icon cache if available
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -f -t "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
fi
case ":$PATH:" in
*":$INSTALL_DIR:"*) ;;
*) echo "Note: add $INSTALL_DIR to your PATH (e.g. export PATH=\"\$HOME/.local/bin:\$PATH\")" ;;
esac
echo "Done! Run 'tmuxido' to get started."

View File

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

View File

@ -3,6 +3,9 @@ pub mod config;
pub mod deps;
pub mod self_update;
pub mod session;
pub mod shortcut;
pub mod ui;
pub mod update_check;
use anyhow::Result;
use cache::ProjectCache;
@ -13,6 +16,14 @@ use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use walkdir::WalkDir;
pub fn setup_shortcut_wizard() -> Result<()> {
shortcut::setup_shortcut_wizard()
}
pub fn setup_desktop_integration_wizard() -> Result<()> {
shortcut::setup_desktop_integration_wizard()
}
pub fn show_cache_status(config: &Config) -> Result<()> {
if !config.cache_enabled {
println!("Cache is disabled in configuration");
@ -38,44 +49,85 @@ pub fn show_cache_status(config: &Config) -> Result<()> {
}
pub fn get_projects(config: &Config, force_refresh: bool) -> Result<Vec<PathBuf>> {
if !config.cache_enabled || force_refresh {
let (projects, fingerprints) = scan_all_roots(config)?;
let cache = ProjectCache::new(projects.clone(), fingerprints);
cache.save()?;
eprintln!("Cache updated with {} projects", projects.len());
return Ok(projects);
}
get_projects_internal(
config,
force_refresh,
&ProjectCache::load,
&|cache| cache.save(),
&scan_all_roots,
&spawn_background_refresh,
)
}
if let Some(mut cache) = ProjectCache::load()? {
// Cache no formato antigo (sem dir_mtimes) → atualizar com rescan completo
/// 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() {
eprintln!("Upgrading cache, scanning for projects...");
// Old cache format — full rescan
let (projects, fingerprints) = scan_all_roots(config)?;
let new_cache = ProjectCache::new(projects.clone(), fingerprints);
new_cache.save()?;
eprintln!("Cache updated with {} projects", projects.len());
return Ok(projects);
}
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()?;
eprintln!(
"Cache updated incrementally ({} projects)",
cache.projects.len()
);
} else {
eprintln!("Using cached projects ({} projects)", cache.projects.len());
}
}
}
}
Ok(())
}
fn spawn_background_refresh() {
if let Ok(exe) = std::env::current_exe() {
std::process::Command::new(exe)
.arg("--background-refresh")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.ok();
}
}
#[allow(clippy::type_complexity)]
fn get_projects_internal(
config: &Config,
force_refresh: bool,
cache_loader: &dyn Fn() -> Result<Option<ProjectCache>>,
cache_saver: &dyn Fn(&ProjectCache) -> Result<()>,
scanner: &dyn Fn(&Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>,
refresh_spawner: &dyn Fn(),
) -> Result<Vec<PathBuf>> {
if !config.cache_enabled || force_refresh {
let (projects, fingerprints) = scanner(config)?;
let cache = ProjectCache::new(projects.clone(), fingerprints);
cache_saver(&cache)?;
return Ok(projects);
}
if let Some(cache) = cache_loader()? {
// Cache exists — return immediately (stale-while-revalidate).
// Spawn a background refresh if the cache is stale or in old format.
let is_stale =
cache.dir_mtimes.is_empty() || cache.age_in_seconds() > config.cache_ttl_hours * 3600;
if is_stale {
refresh_spawner();
}
return Ok(cache.projects);
}
// Sem cache ainda — scan completo inicial
// No cache yet — first run, blocking scan is unavoidable.
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);
cache.save()?;
eprintln!("Cache updated with {} projects", projects.len());
cache_saver(&cache)?;
Ok(projects)
}
@ -162,3 +214,214 @@ pub fn launch_tmux_session(selected: &Path, config: &Config) -> Result<()> {
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

@ -6,7 +6,11 @@ use std::process::{Command, Stdio};
use tmuxido::config::Config;
use tmuxido::deps::ensure_dependencies;
use tmuxido::self_update;
use tmuxido::{get_projects, launch_tmux_session, show_cache_status};
use tmuxido::update_check;
use tmuxido::{
get_projects, launch_tmux_session, refresh_cache, setup_desktop_integration_wizard,
setup_shortcut_wizard, show_cache_status,
};
#[derive(Parser, Debug)]
#[command(
@ -29,6 +33,18 @@ struct Args {
/// 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<()> {
@ -39,6 +55,23 @@ fn main() -> Result<()> {
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()?;
@ -48,6 +81,9 @@ fn main() -> Result<()> {
// Load config
let config = Config::load()?;
// Periodic update check (silent on failure or no update)
update_check::check_and_notify(&config);
// Handle cache status command
if args.cache_status {
show_cache_status(&config)?;
@ -80,8 +116,34 @@ fn main() -> Result<()> {
Ok(())
}
fn readme_preview_command() -> String {
let glow_available = Command::new("sh")
.args(["-c", "command -v glow"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
// CLICOLOR_FORCE=1 tells termenv (used by glow/glamour) to enable ANSI
// colors even when stdout is not a TTY (fzf preview runs in a pipe).
// Without it, glow falls back to bold-only "notty" style with no colors.
// Use `sh -c '...' -- {}` so the command runs in POSIX sh regardless of
// the user's $SHELL (fish, zsh, bash, etc.).
let viewer_cmd = if glow_available {
"CLICOLOR_FORCE=1 glow -s dark"
} else {
"cat"
};
format!(
r#"sh -c 'readme="$1/README.md"; [ -f "$readme" ] && {viewer_cmd} "$readme" || echo "No README.md"' -- {{}}"#
)
}
fn select_project_with_fzf(projects: &[PathBuf]) -> Result<PathBuf> {
let preview_cmd = readme_preview_command();
let mut child = Command::new("fzf")
.arg("--preview")
.arg(&preview_cmd)
.arg("--preview-window")
.arg("right:40%")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()

View File

@ -3,8 +3,9 @@ use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::process::Command;
const REPO: &str = "cinco/Tmuxido";
const BASE_URL: &str = "https://git.cincoeuzebio.com";
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 {
@ -20,18 +21,33 @@ pub fn current_version() -> &'static str {
fn detect_arch() -> Result<&'static str> {
let arch = std::env::consts::ARCH;
match arch {
"x86_64" => Ok("x86_64-linux"),
"aarch64" => Ok("aarch64-linux"),
"x86_64" => Ok("tmuxido-x86_64-linux"),
"aarch64" => Ok("tmuxido-aarch64-linux"),
_ => Err(anyhow::anyhow!("Unsupported architecture: {}", arch)),
}
}
/// Fetch latest release tag from Gitea API
fn fetch_latest_tag() -> Result<String> {
let url = format!("{}/api/v1/repos/{}/releases?limit=1&page=1", BASE_URL, REPO);
/// 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", &url])
.args([
"-fsSL",
"-H",
"Accept: application/vnd.github.v3+json",
&url,
])
.output()
.context("Failed to execute curl. Make sure curl is installed.")?;
@ -42,17 +58,7 @@ fn fetch_latest_tag() -> Result<String> {
));
}
let response = String::from_utf8_lossy(&output.stdout);
// Parse JSON response to extract tag_name
let tag: serde_json::Value =
serde_json::from_str(&response).context("Failed to parse release API response")?;
tag.get(0)
.and_then(|r| r.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"))
parse_latest_tag(&String::from_utf8_lossy(&output.stdout))
}
/// Get path to current executable
@ -166,7 +172,7 @@ pub fn self_update() -> Result<()> {
}
/// Compare two semver versions
fn version_compare(a: &str, b: &str) -> std::cmp::Ordering {
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())
@ -198,6 +204,36 @@ mod tests {
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!(

View File

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

View File

@ -10,6 +10,7 @@ fn make_config(max_depth: usize) -> Config {
max_depth,
cache_enabled: true,
cache_ttl_hours: 24,
update_check_interval_hours: 24,
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