Compare commits

...

32 Commits
0.4.3 ... 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
22 changed files with 3739 additions and 421 deletions

View File

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

View File

@ -4,6 +4,119 @@ 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/). 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 ## [0.4.2] - 2026-03-01
### Fixed ### Fixed

605
Cargo.lock generated
View File

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

View File

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

View File

@ -17,7 +17,7 @@ A Rust-based tool to quickly find and open projects in tmux using fzf. No extern
## Features ## Features
- Search for git repositories in configurable paths - Search for git repositories in configurable paths
- Interactive selection using fzf - Interactive selection using fzf with live `README.md` preview (rendered via `glow` when available)
- Native tmux session creation (no tmuxinator required!) - Native tmux session creation (no tmuxinator required!)
- Support for project-specific `.tmuxido.toml` configs - Support for project-specific `.tmuxido.toml` configs
- Smart session switching (reuses existing sessions) - Smart session switching (reuses existing sessions)
@ -25,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 - Smart caching system for fast subsequent runs
- Configurable cache TTL - Configurable cache TTL
- Self-update capability (`tmuxido --update`) - Self-update capability (`tmuxido --update`)
- Keyboard shortcut setup for Hyprland, GNOME, and KDE (`tmuxido --setup-shortcut`)
- Zero external dependencies (except tmux and fzf) - Zero external dependencies (except tmux and fzf)
## Installation ## Installation
```sh ```sh
curl -fsSL https://git.cincoeuzebio.com/cinco/Tmuxido/raw/branch/main/install.sh | sh curl -fsSL https://raw.githubusercontent.com/cinco/tmuxido/main/install.sh | sh
``` ```
Installs the latest release binary to `~/.local/bin/tmuxido`. On first run, the config file is created automatically at `~/.config/tmuxido/tmuxido.toml`. Installs the latest release binary to `~/.local/bin/tmuxido`. On first run, the config file is created automatically at `~/.config/tmuxido/tmuxido.toml`.
@ -106,6 +107,18 @@ Update tmuxido to the latest version:
tmuxido --update tmuxido --update
``` ```
Set up a desktop keyboard shortcut (Hyprland, GNOME, KDE):
```bash
tmuxido --setup-shortcut
```
Install the `.desktop` entry and icon (so tmuxido appears in app launchers like Walker/Rofi):
```bash
tmuxido --setup-desktop-shortcut
```
Both are also offered automatically on first run. Re-run them any time to reconfigure.
View help: View help:
```bash ```bash
tmuxido --help tmuxido --help
@ -123,7 +136,7 @@ tmuxido --help
3. Presents them using fzf for selection 3. Presents them using fzf for selection
4. Creates or switches to a tmux session for the selected project 4. Creates or switches to a tmux session for the selected project
5. If a `.tmuxido.toml` config exists in the project, uses it to set up custom windows and panes 5. If a `.tmuxido.toml` config exists in the project, uses it to set up custom windows and panes
6. Otherwise, creates a default session with two windows: "editor" and "terminal" 6. Otherwise, uses the default session config from `~/.config/tmuxido/tmuxido.toml` (configured interactively on first run)
## Caching ## Caching
@ -158,11 +171,61 @@ panes = []
### Available Layouts ### Available Layouts
- `main-horizontal` - Main pane on top, others below **`main-horizontal`** — Main pane on top, others below
- `main-vertical` - Main pane on left, others on right
- `tiled` - All panes tiled ```
- `even-horizontal` - All panes in horizontal row ┌──────────────────────┐
- `even-vertical` - All panes in vertical column │ │
│ main pane │
│ │
├──────────┬───────────┤
│ pane 2 │ pane 3 │
└──────────┴───────────┘
```
**`main-vertical`** — Main pane on left, others on right
```
┌─────────────┬────────┐
│ │ pane 2 │
│ main pane ├────────┤
│ │ pane 3 │
│ ├────────┤
│ │ pane 4 │
└─────────────┴────────┘
```
**`tiled`** — All panes tiled equally
```
┌───────────┬──────────┐
│ pane 1 │ pane 2 │
├───────────┼──────────┤
│ pane 3 │ pane 4 │
└───────────┴──────────┘
```
**`even-horizontal`** — All panes side by side
```
┌────────┬────────┬────────┐
│ │ │ │
│ pane 1 │ pane 2 │ pane 3 │
│ │ │ │
└────────┴────────┴────────┘
```
**`even-vertical`** — All panes stacked
```
┌──────────────────────┐
│ pane 1 │
├──────────────────────┤
│ pane 2 │
├──────────────────────┤
│ pane 3 │
└──────────────────────┘
```
### Panes ### Panes

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

View File

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

View File

@ -3,6 +3,9 @@ pub mod config;
pub mod deps; pub mod deps;
pub mod self_update; pub mod self_update;
pub mod session; pub mod session;
pub mod shortcut;
pub mod ui;
pub mod update_check;
use anyhow::Result; use anyhow::Result;
use cache::ProjectCache; use cache::ProjectCache;
@ -13,6 +16,14 @@ use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH; use std::time::UNIX_EPOCH;
use walkdir::WalkDir; use walkdir::WalkDir;
pub fn setup_shortcut_wizard() -> Result<()> {
shortcut::setup_shortcut_wizard()
}
pub fn setup_desktop_integration_wizard() -> Result<()> {
shortcut::setup_desktop_integration_wizard()
}
pub fn show_cache_status(config: &Config) -> Result<()> { pub fn show_cache_status(config: &Config) -> Result<()> {
if !config.cache_enabled { if !config.cache_enabled {
println!("Cache is disabled in configuration"); println!("Cache is disabled in configuration");
@ -44,9 +55,47 @@ pub fn get_projects(config: &Config, force_refresh: bool) -> Result<Vec<PathBuf>
&ProjectCache::load, &ProjectCache::load,
&|cache| cache.save(), &|cache| cache.save(),
&scan_all_roots, &scan_all_roots,
&spawn_background_refresh,
) )
} }
/// Rebuilds the project cache incrementally. Intended to be called from a
/// background process spawned by `get_projects` via stale-while-revalidate.
pub fn refresh_cache(config: &Config) -> Result<()> {
match ProjectCache::load()? {
None => {
let (projects, fingerprints) = scan_all_roots(config)?;
ProjectCache::new(projects, fingerprints).save()?;
}
Some(mut cache) => {
if cache.dir_mtimes.is_empty() {
// Old cache format — full rescan
let (projects, fingerprints) = scan_all_roots(config)?;
ProjectCache::new(projects, fingerprints).save()?;
} else {
// Incremental rescan based on directory mtimes
let changed = cache.validate_and_update(&|root| scan_from_root(root, config))?;
if changed {
cache.save()?;
}
}
}
}
Ok(())
}
fn spawn_background_refresh() {
if let Ok(exe) = std::env::current_exe() {
std::process::Command::new(exe)
.arg("--background-refresh")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.ok();
}
}
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
fn get_projects_internal( fn get_projects_internal(
config: &Config, config: &Config,
@ -54,45 +103,31 @@ fn get_projects_internal(
cache_loader: &dyn Fn() -> Result<Option<ProjectCache>>, cache_loader: &dyn Fn() -> Result<Option<ProjectCache>>,
cache_saver: &dyn Fn(&ProjectCache) -> Result<()>, cache_saver: &dyn Fn(&ProjectCache) -> Result<()>,
scanner: &dyn Fn(&Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>, scanner: &dyn Fn(&Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>,
refresh_spawner: &dyn Fn(),
) -> Result<Vec<PathBuf>> { ) -> Result<Vec<PathBuf>> {
if !config.cache_enabled || force_refresh { if !config.cache_enabled || force_refresh {
let (projects, fingerprints) = scanner(config)?; let (projects, fingerprints) = scanner(config)?;
let cache = ProjectCache::new(projects.clone(), fingerprints); let cache = ProjectCache::new(projects.clone(), fingerprints);
cache_saver(&cache)?; cache_saver(&cache)?;
eprintln!("Cache updated with {} projects", projects.len());
return Ok(projects); return Ok(projects);
} }
if let Some(mut cache) = cache_loader()? { if let Some(cache) = cache_loader()? {
// Cache no formato antigo (sem dir_mtimes) → atualizar com rescan completo // Cache exists — return immediately (stale-while-revalidate).
if cache.dir_mtimes.is_empty() { // Spawn a background refresh if the cache is stale or in old format.
eprintln!("Upgrading cache, scanning for projects..."); let is_stale =
let (projects, fingerprints) = scanner(config)?; cache.dir_mtimes.is_empty() || cache.age_in_seconds() > config.cache_ttl_hours * 3600;
let new_cache = ProjectCache::new(projects.clone(), fingerprints); if is_stale {
cache_saver(&new_cache)?; refresh_spawner();
eprintln!("Cache updated with {} projects", projects.len());
return Ok(projects);
}
let changed = cache.validate_and_update(&|root| scan_from_root(root, config))?;
if changed {
cache_saver(&cache)?;
eprintln!(
"Cache updated incrementally ({} projects)",
cache.projects.len()
);
} else {
eprintln!("Using cached projects ({} projects)", cache.projects.len());
} }
return Ok(cache.projects); return Ok(cache.projects);
} }
// Sem cache ainda — scan completo inicial // No cache yet — first run, blocking scan is unavoidable.
eprintln!("No cache found, scanning for projects..."); eprintln!("No cache found, scanning for projects...");
let (projects, fingerprints) = scanner(config)?; let (projects, fingerprints) = scanner(config)?;
let cache = ProjectCache::new(projects.clone(), fingerprints); let cache = ProjectCache::new(projects.clone(), fingerprints);
cache_saver(&cache)?; cache_saver(&cache)?;
eprintln!("Cache updated with {} projects", projects.len());
Ok(projects) Ok(projects)
} }
@ -185,241 +220,208 @@ mod tests {
use super::*; use super::*;
use std::cell::RefCell; use std::cell::RefCell;
fn create_test_config(cache_enabled: bool) -> Config { fn make_config(cache_enabled: bool, cache_ttl_hours: u64) -> Config {
Config { Config {
paths: vec!["/tmp/test".to_string()], paths: vec!["/tmp/test".to_string()],
max_depth: 3, max_depth: 3,
cache_enabled, cache_enabled,
cache_ttl_hours: 24, cache_ttl_hours,
update_check_interval_hours: 24,
default_session: session::SessionConfig { windows: vec![] }, 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] #[test]
fn should_scan_when_cache_disabled() { fn should_scan_when_cache_disabled() {
let config = create_test_config(false); let config = make_config(false, 24);
let projects = vec![PathBuf::from("/tmp/test/project1")]; let expected = vec![PathBuf::from("/p1")];
let fingerprints = HashMap::new();
let expected_projects = projects.clone();
let scanner_called = RefCell::new(false); let scanner_called = RefCell::new(false);
let saver_called = RefCell::new(false); let saver_called = RefCell::new(false);
let spawner_called = RefCell::new(false);
let result = get_projects_internal( let result = call_internal(
&config, &config,
false, false,
&|| panic!("should not load cache when disabled"), &|| panic!("loader must not be called when cache disabled"),
&|_| { &|_| {
*saver_called.borrow_mut() = true; *saver_called.borrow_mut() = true;
Ok(()) Ok(())
}, },
&|_| { &|_| {
*scanner_called.borrow_mut() = true; *scanner_called.borrow_mut() = true;
Ok((expected_projects.clone(), fingerprints.clone())) Ok((expected.clone(), HashMap::new()))
}, },
&|| *spawner_called.borrow_mut() = true,
); );
assert!(result.is_ok()); assert!(result.is_ok());
assert!(scanner_called.into_inner()); assert!(scanner_called.into_inner());
assert!(saver_called.into_inner()); assert!(saver_called.into_inner());
assert_eq!(result.unwrap(), projects); assert!(!spawner_called.into_inner());
assert_eq!(result.unwrap(), expected);
} }
#[test] #[test]
fn should_scan_when_force_refresh() { fn should_scan_when_force_refresh() {
let config = create_test_config(true); let config = make_config(true, 24);
let projects = vec![PathBuf::from("/tmp/test/project1")]; let expected = vec![PathBuf::from("/p1")];
let fingerprints = HashMap::new();
let expected_projects = projects.clone();
let scanner_called = RefCell::new(false); let scanner_called = RefCell::new(false);
let saver_called = RefCell::new(false); let saver_called = RefCell::new(false);
let spawner_called = RefCell::new(false);
let result = get_projects_internal( let result = call_internal(
&config, &config,
true, true,
&|| panic!("should not load cache when force refresh"), &|| panic!("loader must not be called on force refresh"),
&|_| { &|_| {
*saver_called.borrow_mut() = true; *saver_called.borrow_mut() = true;
Ok(()) Ok(())
}, },
&|_| { &|_| {
*scanner_called.borrow_mut() = true; *scanner_called.borrow_mut() = true;
Ok((expected_projects.clone(), fingerprints.clone())) Ok((expected.clone(), HashMap::new()))
}, },
&|| *spawner_called.borrow_mut() = true,
); );
assert!(result.is_ok()); assert!(result.is_ok());
assert!(scanner_called.into_inner()); assert!(scanner_called.into_inner());
assert!(saver_called.into_inner()); assert!(saver_called.into_inner());
assert_eq!(result.unwrap(), projects); assert!(!spawner_called.into_inner());
assert_eq!(result.unwrap(), expected);
} }
#[test] #[test]
fn should_do_initial_scan_when_no_cache_exists() { fn should_do_blocking_scan_when_no_cache_exists() {
let config = create_test_config(true); let config = make_config(true, 24);
let projects = vec![PathBuf::from("/tmp/test/project1")]; let expected = vec![PathBuf::from("/p1")];
let fingerprints = HashMap::new();
let expected_projects = projects.clone();
let loader_called = RefCell::new(false);
let scanner_called = RefCell::new(false); let scanner_called = RefCell::new(false);
let saver_called = RefCell::new(false); let saver_called = RefCell::new(false);
let spawner_called = RefCell::new(false);
let result = get_projects_internal( let result = call_internal(
&config, &config,
false, false,
&|| { &|| Ok(None),
*loader_called.borrow_mut() = true;
Ok(None)
},
&|_| { &|_| {
*saver_called.borrow_mut() = true; *saver_called.borrow_mut() = true;
Ok(()) Ok(())
}, },
&|_| { &|_| {
*scanner_called.borrow_mut() = true; *scanner_called.borrow_mut() = true;
Ok((expected_projects.clone(), fingerprints.clone())) Ok((expected.clone(), HashMap::new()))
}, },
&|| *spawner_called.borrow_mut() = true,
); );
assert!(result.is_ok()); assert!(result.is_ok());
assert!(loader_called.into_inner());
assert!(scanner_called.into_inner()); assert!(scanner_called.into_inner());
assert!(saver_called.into_inner()); assert!(saver_called.into_inner());
assert_eq!(result.unwrap(), projects); assert!(!spawner_called.into_inner());
assert_eq!(result.unwrap(), expected);
} }
#[test] #[test]
fn should_upgrade_old_cache_format() { fn should_return_cached_projects_immediately_when_cache_is_fresh() {
let config = create_test_config(true); let config = make_config(true, 24);
let old_projects = vec![PathBuf::from("/old/project")]; let cached = vec![PathBuf::from("/cached/project")];
let new_projects = vec![ let cache = RefCell::new(Some(fresh_cache(cached.clone())));
PathBuf::from("/new/project1"), let spawner_called = RefCell::new(false);
PathBuf::from("/new/project2"),
];
let new_fingerprints = HashMap::from([(PathBuf::from("/new"), 12345u64)]);
// Use RefCell<Option<>> to allow moving into closure multiple times let result = call_internal(
let old_cache = RefCell::new(Some(ProjectCache::new(old_projects, HashMap::new())));
let loader_called = RefCell::new(false);
let scanner_called = RefCell::new(false);
let saver_count = RefCell::new(0);
let result = get_projects_internal(
&config, &config,
false, false,
&|| { &|| Ok(cache.borrow_mut().take()),
*loader_called.borrow_mut() = true; &|_| panic!("saver must not be called in foreground"),
// Take the cache out of the RefCell &|_| panic!("scanner must not be called when cache is fresh"),
Ok(old_cache.borrow_mut().take()) &|| *spawner_called.borrow_mut() = true,
},
&|_| {
*saver_count.borrow_mut() += 1;
Ok(())
},
&|_| {
*scanner_called.borrow_mut() = true;
Ok((new_projects.clone(), new_fingerprints.clone()))
},
); );
assert!(result.is_ok()); assert!(result.is_ok());
assert!(loader_called.into_inner()); assert_eq!(result.unwrap(), cached);
assert!(scanner_called.into_inner()); assert!(
assert_eq!(*saver_count.borrow(), 1); !spawner_called.into_inner(),
assert_eq!(result.unwrap(), new_projects); "fresh cache should not trigger background refresh"
);
} }
#[test] #[test]
fn should_use_cached_projects_when_nothing_changed() { fn should_return_stale_cache_immediately_and_spawn_background_refresh() {
let config = create_test_config(true); let config = make_config(true, 24);
let cached_projects = vec![ let cached = vec![PathBuf::from("/cached/project")];
PathBuf::from("/nonexistent/project1"), let cache = RefCell::new(Some(stale_cache(cached.clone())));
PathBuf::from("/nonexistent/project2"), let spawner_called = RefCell::new(false);
];
// Use a path that doesn't exist - validate_and_update will skip rescan
// because it can't check mtime of non-existent directory
let cached_fingerprints =
HashMap::from([(PathBuf::from("/definitely_nonexistent_path_xyz"), 12345u64)]);
// Use RefCell<Option<>> to allow moving into closure multiple times let result = call_internal(
let cache = RefCell::new(Some(ProjectCache::new(
cached_projects.clone(),
cached_fingerprints,
)));
let loader_called = RefCell::new(false);
let scanner_called = RefCell::new(false);
let saver_count = RefCell::new(0);
let result = get_projects_internal(
&config, &config,
false, false,
&|| { &|| Ok(cache.borrow_mut().take()),
*loader_called.borrow_mut() = true; &|_| panic!("saver must not be called in foreground"),
// Take the cache out of the RefCell &|_| panic!("scanner must not be called in foreground"),
Ok(cache.borrow_mut().take()) &|| *spawner_called.borrow_mut() = true,
},
&|_| {
*saver_count.borrow_mut() += 1;
Ok(())
},
&|_| {
*scanner_called.borrow_mut() = true;
panic!("should not do full scan when cache is valid")
},
); );
assert!(result.is_ok()); assert!(result.is_ok());
assert!(loader_called.into_inner()); assert_eq!(result.unwrap(), cached);
// Note: When the directory in dir_mtimes doesn't exist, validate_and_update assert!(
// treats it as "changed" and removes projects under that path. spawner_called.into_inner(),
// This test verifies the flow completes - the specific behavior of "stale cache must trigger background refresh"
// validate_and_update is tested separately in cache.rs );
let result_projects = result.unwrap();
// Projects were removed because the tracked directory doesn't exist
assert!(result_projects.is_empty());
} }
#[test] #[test]
fn should_update_incrementally_when_cache_changed() { fn should_spawn_background_refresh_when_cache_has_no_fingerprints() {
let config = create_test_config(true); let config = make_config(true, 24);
let initial_projects = vec![PathBuf::from("/nonexistent/project1")]; let cached = vec![PathBuf::from("/old/project")];
// Use a path that doesn't exist - validate_and_update will treat missing // Old cache format: no dir_mtimes
// directory as a change (unwrap_or(true) in the mtime check) let old_cache = RefCell::new(Some(ProjectCache::new(cached.clone(), HashMap::new())));
let mut dir_mtimes = HashMap::new(); let spawner_called = RefCell::new(false);
dir_mtimes.insert(PathBuf::from("/definitely_nonexistent_path_abc"), 0u64);
// Use RefCell<Option<>> to allow moving into closure multiple times let result = call_internal(
let cache = RefCell::new(Some(ProjectCache::new(initial_projects, dir_mtimes)));
let loader_called = RefCell::new(false);
let saver_called = RefCell::new(false);
let result = get_projects_internal(
&config, &config,
false, false,
&|| { &|| Ok(old_cache.borrow_mut().take()),
*loader_called.borrow_mut() = true; &|_| panic!("saver must not be called in foreground"),
// Take the cache out of the RefCell &|_| panic!("scanner must not be called in foreground"),
Ok(cache.borrow_mut().take()) &|| *spawner_called.borrow_mut() = true,
},
&|_| {
*saver_called.borrow_mut() = true;
Ok(())
},
&|_| panic!("full scan should not happen with incremental update"),
); );
// validate_and_update is called internally. Since the directory doesn't exist,
// it treats it as "changed" and will try to rescan using scan_from_root.
// We verify the flow completes without panicking.
assert!(result.is_ok()); assert!(result.is_ok());
assert!(loader_called.into_inner()); assert_eq!(result.unwrap(), cached);
// Note: The saver may or may not be called depending on whether assert!(
// validate_and_update detects changes (missing dir = change) 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::config::Config;
use tmuxido::deps::ensure_dependencies; use tmuxido::deps::ensure_dependencies;
use tmuxido::self_update; 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)] #[derive(Parser, Debug)]
#[command( #[command(
@ -29,6 +33,18 @@ struct Args {
/// Update tmuxido to the latest version /// Update tmuxido to the latest version
#[arg(long)] #[arg(long)]
update: bool, update: bool,
/// Set up a keyboard shortcut to launch tmuxido
#[arg(long)]
setup_shortcut: bool,
/// Install the .desktop entry and icon for app launcher integration
#[arg(long)]
setup_desktop_shortcut: bool,
/// Internal: rebuild cache in background (used by stale-while-revalidate)
#[arg(long, hide = true)]
background_refresh: bool,
} }
fn main() -> Result<()> { fn main() -> Result<()> {
@ -39,6 +55,23 @@ fn main() -> Result<()> {
return self_update::self_update(); return self_update::self_update();
} }
// Handle background cache refresh (spawned internally by stale-while-revalidate).
// Runs early to avoid unnecessary dependency checks and config prompts.
if args.background_refresh {
let config = Config::load()?;
return refresh_cache(&config);
}
// Handle standalone shortcut setup
if args.setup_shortcut {
return setup_shortcut_wizard();
}
// Handle standalone desktop integration setup
if args.setup_desktop_shortcut {
return setup_desktop_integration_wizard();
}
// Check that fzf and tmux are installed; offer to install if missing // Check that fzf and tmux are installed; offer to install if missing
ensure_dependencies()?; ensure_dependencies()?;
@ -48,6 +81,9 @@ fn main() -> Result<()> {
// Load config // Load config
let config = Config::load()?; let config = Config::load()?;
// Periodic update check (silent on failure or no update)
update_check::check_and_notify(&config);
// Handle cache status command // Handle cache status command
if args.cache_status { if args.cache_status {
show_cache_status(&config)?; show_cache_status(&config)?;
@ -80,8 +116,34 @@ fn main() -> Result<()> {
Ok(()) Ok(())
} }
fn readme_preview_command() -> String {
let glow_available = Command::new("sh")
.args(["-c", "command -v glow"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
// CLICOLOR_FORCE=1 tells termenv (used by glow/glamour) to enable ANSI
// colors even when stdout is not a TTY (fzf preview runs in a pipe).
// Without it, glow falls back to bold-only "notty" style with no colors.
// Use `sh -c '...' -- {}` so the command runs in POSIX sh regardless of
// the user's $SHELL (fish, zsh, bash, etc.).
let viewer_cmd = if glow_available {
"CLICOLOR_FORCE=1 glow -s dark"
} else {
"cat"
};
format!(
r#"sh -c 'readme="$1/README.md"; [ -f "$readme" ] && {viewer_cmd} "$readme" || echo "No README.md"' -- {{}}"#
)
}
fn select_project_with_fzf(projects: &[PathBuf]) -> Result<PathBuf> { fn select_project_with_fzf(projects: &[PathBuf]) -> Result<PathBuf> {
let preview_cmd = readme_preview_command();
let mut child = Command::new("fzf") let mut child = Command::new("fzf")
.arg("--preview")
.arg(&preview_cmd)
.arg("--preview-window")
.arg("right:40%")
.stdin(Stdio::piped()) .stdin(Stdio::piped())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.spawn() .spawn()

View File

@ -3,8 +3,9 @@ use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command; use std::process::Command;
const REPO: &str = "cinco/Tmuxido"; const REPO: &str = "cinco/tmuxido";
const BASE_URL: &str = "https://git.cincoeuzebio.com"; const BASE_URL: &str = "https://github.com";
const API_BASE: &str = "https://api.github.com";
/// Check if running from cargo (development mode) /// Check if running from cargo (development mode)
fn is_dev_build() -> bool { fn is_dev_build() -> bool {
@ -20,18 +21,33 @@ pub fn current_version() -> &'static str {
fn detect_arch() -> Result<&'static str> { fn detect_arch() -> Result<&'static str> {
let arch = std::env::consts::ARCH; let arch = std::env::consts::ARCH;
match arch { match arch {
"x86_64" => Ok("x86_64-linux"), "x86_64" => Ok("tmuxido-x86_64-linux"),
"aarch64" => Ok("aarch64-linux"), "aarch64" => Ok("tmuxido-aarch64-linux"),
_ => Err(anyhow::anyhow!("Unsupported architecture: {}", arch)), _ => Err(anyhow::anyhow!("Unsupported architecture: {}", arch)),
} }
} }
/// Fetch latest release tag from Gitea API /// Parse tag_name from a GitHub releases/latest JSON response
fn fetch_latest_tag() -> Result<String> { fn parse_latest_tag(response: &str) -> Result<String> {
let url = format!("{}/api/v1/repos/{}/releases?limit=1&page=1", BASE_URL, REPO); 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") let output = Command::new("curl")
.args(["-fsSL", &url]) .args([
"-fsSL",
"-H",
"Accept: application/vnd.github.v3+json",
&url,
])
.output() .output()
.context("Failed to execute curl. Make sure curl is installed.")?; .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_latest_tag(&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"))
} }
/// Get path to current executable /// Get path to current executable
@ -166,7 +172,7 @@ pub fn self_update() -> Result<()> {
} }
/// Compare two semver versions /// 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| { let parse = |s: &str| {
s.split('.') s.split('.')
.filter_map(|n| n.parse::<u32>().ok()) .filter_map(|n| n.parse::<u32>().ok())
@ -198,6 +204,36 @@ mod tests {
assert!(version.contains('.')); 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] #[test]
fn should_compare_versions_correctly() { fn should_compare_versions_correctly() {
assert_eq!( assert_eq!(

View File

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

984
src/shortcut.rs Normal file
View File

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

1016
src/ui.rs Normal file

File diff suppressed because it is too large Load Diff

219
src/update_check.rs Normal file
View File

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

View File

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

165
tests/shortcut.rs Normal file
View File

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

10
tmuxido.desktop Normal file
View File

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