Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3380c668a | |||
| 8aa341080d | |||
| 2abf7e77b4 | |||
| 2d7d49d548 | |||
| ee2059986c | |||
| 2da5715a34 | |||
| 4263f0379d | |||
| a8f88e852c | |||
| 0e2745acf1 | |||
| d7246298b1 | |||
| 3aacf30697 | |||
| 906eec994f | |||
| d0f6592729 | |||
| 3f72128a25 | |||
| cc16cde540 | |||
| db08840b64 | |||
| 4ecdf96db8 | |||
| b05c188477 | |||
| 75d66cd47c | |||
| 973042ce7d | |||
| 0f27bedc94 | |||
| da6311bc53 | |||
| 2b1773375a | |||
| 36aaa65945 | |||
| 10a38a1f85 | |||
| 42bdc1d409 | |||
| a592c99375 | |||
| ff6050c718 | |||
| 15a11ef79c | |||
| 6050cb70f3 | |||
| 61f6a9fee3 | |||
| e0da58d114 | |||
| 437584aac7 | |||
| 960724685c | |||
| 639bcdf643 | |||
| ddb4b70234 | |||
| 32155bc1d2 | |||
| e4cc280f28 | |||
| 868540b92a | |||
| 0af46bd6a5 | |||
| ba3f923781 | |||
| fcb7c7f6a6 | |||
| 3c78a5afe3 | |||
| 4dcac9a6aa | |||
| 32c40b7226 | |||
| c1adc92c1c | |||
| e6180b57c3 | |||
| 90e3b65820 | |||
| fc2f503886 | |||
| b29baee9c4 | |||
| 6d842b83ba | |||
| b4877007f2 | |||
| 29ff9535a7 | |||
| dec566320f | |||
| 6275c638d9 | |||
| 4d6af13134 | |||
| b2a0ee5e08 | |||
| 2bc516ed6d | |||
| 7e70157097 | |||
| ef13e884ba | |||
| b6e33c37ac | |||
| 20f516cffb | |||
| a5429c3543 | |||
| 8a7df892e8 | |||
| f0949e08c5 | |||
| d80857090e | |||
| 19f36060a0 | |||
| 2dc12e37c9 | |||
| 280a180d3e | |||
| 5f281b1f9e |
@ -3,10 +3,14 @@
|
|||||||
"PostToolUse": [
|
"PostToolUse": [
|
||||||
{
|
{
|
||||||
"matcher": "Write|Edit",
|
"matcher": "Write|Edit",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "bash -c 'if [ -f Cargo.toml ]; then if command -v cargo-nextest >/dev/null 2>&1; then cargo nextest run 2>&1; else cargo test 2>&1; fi; fi'",
|
"command": "bash -c 'if [ -f Cargo.toml ]; then if command -v cargo-nextest >/dev/null 2>&1; then cargo nextest run 2>&1; else cargo test 2>&1; fi; fi'",
|
||||||
"timeout": 120
|
"timeout": 120
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
target/
|
||||||
|
.git/
|
||||||
|
.claude/
|
||||||
93
.drone.yml
93
.drone.yml
@ -15,6 +15,35 @@ steps:
|
|||||||
- cargo clippy -- -D warnings
|
- cargo clippy -- -D warnings
|
||||||
- cargo test
|
- cargo test
|
||||||
|
|
||||||
|
- name: coverage
|
||||||
|
image: xd009642/tarpaulin
|
||||||
|
privileged: true
|
||||||
|
environment:
|
||||||
|
GITEA_TOKEN:
|
||||||
|
from_secret: gitea_token
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
apt-get update -qq && apt-get install -y -qq jq curl
|
||||||
|
PCT=$(cargo tarpaulin 2>&1 | awk '/coverage,/{print int($1)}')
|
||||||
|
[ -z "$PCT" ] && PCT=0
|
||||||
|
if [ "$PCT" -ge 80 ]; then FILL="#4c1"
|
||||||
|
elif [ "$PCT" -ge 60 ]; then FILL="#dfb317"
|
||||||
|
else FILL="#e05d44"; fi
|
||||||
|
echo "PCT=$PCT FILL=$FILL"
|
||||||
|
printf '<svg xmlns="http://www.w3.org/2000/svg" width="120" height="20"><rect width="76" height="20" fill="#555"/><rect x="76" width="44" height="20" fill="%s"/><g fill="#fff" text-anchor="middle" font-family="sans-serif" font-size="11"><text x="38" y="14">coverage</text><text x="98" y="14">%s%%</text></g></svg>' "$FILL" "$PCT" > coverage.svg
|
||||||
|
CONTENT=$(base64 coverage.svg | tr -d '\n')
|
||||||
|
SHA=$(curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/contents/coverage.svg?ref=badges" \
|
||||||
|
| jq -r '.sha')
|
||||||
|
jq -n --arg msg "ci: update coverage badge [CI SKIP]" \
|
||||||
|
--arg content "$CONTENT" --arg sha "$SHA" --arg branch "badges" \
|
||||||
|
'{message: $msg, content: $content, sha: $sha, branch: $branch}' \
|
||||||
|
| curl -fsSL -X PUT \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/contents/coverage.svg" \
|
||||||
|
-d @-
|
||||||
|
|
||||||
---
|
---
|
||||||
kind: pipeline
|
kind: pipeline
|
||||||
type: docker
|
type: docker
|
||||||
@ -23,6 +52,8 @@ name: release
|
|||||||
trigger:
|
trigger:
|
||||||
event:
|
event:
|
||||||
- tag
|
- tag
|
||||||
|
ref:
|
||||||
|
- refs/tags/[0-9]*
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: build-x86_64
|
- name: build-x86_64
|
||||||
@ -45,11 +76,29 @@ steps:
|
|||||||
commands:
|
commands:
|
||||||
- apk add --no-cache curl jq
|
- apk add --no-cache curl jq
|
||||||
- |
|
- |
|
||||||
|
# Delete existing release for this tag if present (handles retag scenarios)
|
||||||
|
EXISTING_ID=$(curl -fsSL \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/releases/tags/$DRONE_TAG" \
|
||||||
|
| jq -r '.id // empty')
|
||||||
|
if [ -n "$EXISTING_ID" ]; then
|
||||||
|
curl -fsSL -X DELETE \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/releases/$EXISTING_ID"
|
||||||
|
fi
|
||||||
|
# Read DRONE_TAG via ENVIRON inside awk to avoid Drone's ${VAR} substitution
|
||||||
|
# which would replace ${TAG} with an empty string before the shell runs.
|
||||||
|
BODY=$(awk '
|
||||||
|
BEGIN { tag = ENVIRON["DRONE_TAG"] }
|
||||||
|
/^## \[/ { in_section = (index($0, "[" tag "]") > 0); next }
|
||||||
|
in_section && /^## \[/ { exit }
|
||||||
|
in_section { print }
|
||||||
|
' CHANGELOG.md)
|
||||||
RELEASE_ID=$(curl -fsSL -X POST \
|
RELEASE_ID=$(curl -fsSL -X POST \
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/releases" \
|
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/releases" \
|
||||||
-d "{\"tag_name\":\"$DRONE_TAG\",\"name\":\"$DRONE_TAG\",\"body\":\"Release $DRONE_TAG\"}" \
|
-d "{\"tag_name\":\"$DRONE_TAG\",\"name\":\"$DRONE_TAG\",\"body\":$(echo "$BODY" | jq -Rs .)}" \
|
||||||
| jq -r .id)
|
| jq -r .id)
|
||||||
for ASSET in tmuxido-x86_64-linux tmuxido-aarch64-linux; do
|
for ASSET in tmuxido-x86_64-linux tmuxido-aarch64-linux; do
|
||||||
curl -fsSL -X POST \
|
curl -fsSL -X POST \
|
||||||
@ -60,3 +109,45 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- build-x86_64
|
- build-x86_64
|
||||||
- build-aarch64
|
- build-aarch64
|
||||||
|
|
||||||
|
- name: publish-github
|
||||||
|
image: alpine
|
||||||
|
environment:
|
||||||
|
GITHUB_TOKEN:
|
||||||
|
from_secret: GITHUB_TOKEN
|
||||||
|
GITHUB_REPO: cinco/tmuxido
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache curl jq
|
||||||
|
- |
|
||||||
|
EXISTING=$(curl -fsSL \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
-H "Accept: application/vnd.github.v3+json" \
|
||||||
|
"https://api.github.com/repos/$GITHUB_REPO/releases/tags/$DRONE_TAG" | jq -r '.id // empty')
|
||||||
|
if [ -n "$EXISTING" ]; then
|
||||||
|
curl -s -X DELETE \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/repos/$GITHUB_REPO/releases/$EXISTING"
|
||||||
|
fi
|
||||||
|
BODY=$(awk '
|
||||||
|
BEGIN { tag = ENVIRON["DRONE_TAG"] }
|
||||||
|
/^## \[/ { in_section = (index($0, "[" tag "]") > 0); next }
|
||||||
|
in_section && /^## \[/ { exit }
|
||||||
|
in_section { print }
|
||||||
|
' CHANGELOG.md)
|
||||||
|
RELEASE_ID=$(curl -fsSL -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
-H "Accept: application/vnd.github.v3+json" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"https://api.github.com/repos/$GITHUB_REPO/releases" \
|
||||||
|
-d "{\"tag_name\":\"$DRONE_TAG\",\"name\":\"$DRONE_TAG\",\"body\":$(echo "$BODY" | jq -Rs .)}" \
|
||||||
|
| jq -r '.id')
|
||||||
|
for FILE in tmuxido-x86_64-linux tmuxido-aarch64-linux; do
|
||||||
|
curl -fsSL -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
"https://uploads.github.com/repos/$GITHUB_REPO/releases/$RELEASE_ID/assets?name=$FILE" \
|
||||||
|
--data-binary @"$FILE"
|
||||||
|
done
|
||||||
|
depends_on:
|
||||||
|
- build-x86_64
|
||||||
|
- build-aarch64
|
||||||
|
|||||||
10
.mcp.json
10
.mcp.json
@ -9,6 +9,16 @@
|
|||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"command": "crates-mcp",
|
"command": "crates-mcp",
|
||||||
"args": []
|
"args": []
|
||||||
|
},
|
||||||
|
"drone-ci-mcp": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"drone-ci-mcp",
|
||||||
|
"--access-token=${DRONE_TOKEN}",
|
||||||
|
"--server-url=https://drone.cincoeuzebio.com"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
187
CHANGELOG.md
Normal file
187
CHANGELOG.md
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
|
|
||||||
|
## [0.10.0] - 2026-03-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- fzf preview panel: hovering over a project shows its `README.md` in the right 40% of the screen
|
||||||
|
- Uses `glow` for rendered markdown when available, falls back to `cat`
|
||||||
|
- `CLICOLOR_FORCE=1` ensures glow outputs full ANSI colors even in fzf's non-TTY preview pipe
|
||||||
|
- Preview command runs via `sh -c '...' -- {}` for compatibility with fish, zsh, and bash
|
||||||
|
|
||||||
|
## [0.9.2] - 2026-03-04
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Cache now uses stale-while-revalidate: cached projects are returned immediately and a background process (`--background-refresh`) rebuilds the cache when it is stale, eliminating blocking scans on every invocation
|
||||||
|
- `cache_ttl_hours` is now enforced: when the cache age exceeds the configured TTL, a background refresh is triggered automatically
|
||||||
|
|
||||||
|
## [0.9.1] - 2026-03-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Shortcut and desktop integration wizards are now offered regardless of whether the user chose the interactive wizard or the default config on first run; previously they were only offered in the wizard path
|
||||||
|
|
||||||
|
## [0.9.0] - 2026-03-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- First-run setup choice prompt: when no configuration file exists, tmuxido now asks whether to run the interactive wizard or apply sensible defaults immediately
|
||||||
|
- `SetupChoice` enum and `parse_setup_choice_input` in `ui` module (pure, fully tested)
|
||||||
|
- `Config::write_default_config` helper for writing defaults without any prompts
|
||||||
|
- `Config::run_wizard` extracted from `ensure_config_exists` for clarity and testability
|
||||||
|
- `render_setup_choice_prompt` and `render_default_config_saved` render functions
|
||||||
|
|
||||||
|
## [0.8.3] - 2026-03-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `Cargo.lock` now committed alongside version bumps
|
||||||
|
|
||||||
|
## [0.8.2] - 2026-03-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `install.sh`: grep pattern for `tag_name` now handles the space GitHub includes after the colon in JSON (`"tag_name": "x"` instead of `"tag_name":"x"`)
|
||||||
|
|
||||||
|
## [0.8.1] - 2026-03-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `install.sh`: removed `-f` flag from GitHub API `curl` call so HTTP error responses (rate limits, 404s) are printed instead of silently discarded; shows up to 400 bytes of the raw API response when the release tag cannot be parsed
|
||||||
|
|
||||||
|
## [0.8.0] - 2026-03-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Keyboard shortcut setup wizard on first run and via `tmuxido --setup-shortcut`
|
||||||
|
- Auto-detects desktop environment from `XDG_CURRENT_DESKTOP` / `HYPRLAND_INSTANCE_SIGNATURE`
|
||||||
|
- Hyprland: appends `bindd` entry to `~/.config/hypr/bindings.conf`; prefers `omarchy-launch-tui` when available, falls back to `xdg-terminal-exec`
|
||||||
|
- GNOME: registers a custom keybinding via `gsettings`
|
||||||
|
- KDE: appends a `[tmuxido]` section to `~/.config/kglobalshortcutsrc`
|
||||||
|
- Conflict detection per DE (Hyprland via `hyprctl binds -j`, KDE via config file, GNOME via gsettings); suggests next free combo from a fallback list
|
||||||
|
- `--setup-desktop-shortcut` flag to (re-)install the `.desktop` entry and icon at any time
|
||||||
|
- `shortcut` module (`src/shortcut.rs`) with full unit and integration test coverage
|
||||||
|
- Icon and `.desktop` file installed by `install.sh` and offered in the first-run wizard
|
||||||
|
|
||||||
|
## [0.7.1] - 2026-03-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Interactive setup wizard now asks for a tmux layout when a window has 2 or more panes
|
||||||
|
- Layout selection shown in post-wizard summary
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- README: Added ASCII art previews for each available tmux layout
|
||||||
|
|
||||||
|
## [0.7.0] - 2026-03-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `install.sh` now downloads from GitHub Releases
|
||||||
|
- Self-update now queries the GitHub Releases API for new versions
|
||||||
|
- Releases are published to both Gitea and GitHub
|
||||||
|
|
||||||
|
## [0.6.0] - 2026-03-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Periodic update check: on startup, if `update_check_interval_hours` have elapsed since
|
||||||
|
the last check, tmuxido fetches the latest release tag from the Gitea API and prints a
|
||||||
|
notice when a newer version is available (silent on network failure or no update found)
|
||||||
|
- New `update_check` module (`src/update_check.rs`) with injected fetcher for testability
|
||||||
|
- `update_check_interval_hours` config field (default 24, set to 0 to disable)
|
||||||
|
- Cache file `~/.cache/tmuxido/update_check.json` tracks last-checked timestamp and
|
||||||
|
latest known version across runs
|
||||||
|
|
||||||
|
## [0.5.2] - 2026-03-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Test for `detect_arch` asserting asset name follows `tmuxido-{arch}-linux` format
|
||||||
|
|
||||||
|
## [0.5.1] - 2026-03-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Tmux window creation now targets windows by name instead of numeric index, eliminating
|
||||||
|
"index in use" and "can't find window" errors when `base-index` is not 0
|
||||||
|
- Self-update asset name corrected from `x86_64-linux` to `tmuxido-x86_64-linux` to match
|
||||||
|
what CI actually uploads, fixing 404 on `--update`
|
||||||
|
- CI release pipeline now deletes any existing release for the tag before recreating,
|
||||||
|
preventing 409 Conflict errors on retagged releases
|
||||||
|
|
||||||
|
## [0.5.0] - 2026-03-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Interactive configuration wizard on first run with styled prompts
|
||||||
|
- `lipgloss` dependency for beautiful terminal UI with Tokyo Night theme colors
|
||||||
|
- Emoji-enhanced prompts and feedback during setup
|
||||||
|
- Configure project paths interactively with comma-separated input
|
||||||
|
- Configure `max_depth` for project discovery scanning
|
||||||
|
- Configure cache settings (`cache_enabled`, `cache_ttl_hours`)
|
||||||
|
- Configure default session windows interactively
|
||||||
|
- Configure panes within each window with custom names
|
||||||
|
- Configure startup commands for each pane (e.g., `nvim .`, `npm run dev`)
|
||||||
|
- New `ui` module with styled render functions for all prompts
|
||||||
|
- Comprehensive summary showing all configured settings after setup
|
||||||
|
|
||||||
|
## [0.4.2] - 2026-03-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Version mismatch: bumped Cargo.toml version to match release tag, fixing `--update` false positive
|
||||||
|
|
||||||
|
## [0.4.1] - 2026-03-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Self-update feature (`tmuxido --update`) to update binary from latest GitHub release
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-03-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Self-update feature (`tmuxido --update`) to update binary from latest GitHub release
|
||||||
|
- New `self_update` module with version comparison and atomic binary replacement
|
||||||
|
- `--update` CLI flag for in-place binary updates
|
||||||
|
- Backup and rollback mechanism if update fails
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-03-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Dependency check for `fzf` and `tmux` at startup, before any operation
|
||||||
|
- Automatic Linux package manager detection (apt, pacman, dnf, yum, zypper, emerge, xbps, apk)
|
||||||
|
- Interactive installation prompt when required tools are missing
|
||||||
|
- `deps` module with injectable `BinaryChecker` trait for unit testing without hitting the real system
|
||||||
|
- Integration tests in `tests/deps.rs` (11 tests using real `SystemBinaryChecker`)
|
||||||
|
- Docker test suite in `tests/docker/` with 15 scenarios simulating a fresh Ubuntu 24.04 user
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Release pipeline `publish` step now reads `DRONE_TAG` via awk `ENVIRON` to prevent Drone's
|
||||||
|
`${VAR}` substitution from wiping local shell variables before the shell runs
|
||||||
|
|
||||||
|
## [0.2.4] - 2026-03-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Coverage percentage calculation in CI (correct field from tarpaulin JSON output)
|
||||||
|
- Release pipeline trigger now matches `v*` tag format instead of `[0-9]*`
|
||||||
|
|
||||||
|
## [0.2.2] - 2026-02-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Coverage badge generated by `cargo-tarpaulin` in CI, hosted in Gitea Generic Package Registry
|
||||||
|
- CI status, coverage, version, and Rust edition badges in README
|
||||||
|
|
||||||
|
## [0.2.1] - 2026-02-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Drone CI pipeline (`ci`) running `cargo fmt --check`, `cargo clippy`, and `cargo test` on every push and pull request
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-02-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Unit tests for `cache`, `session`, and `config` modules
|
||||||
|
- Integration tests for scan, session config, and cache lifecycle
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Refactored business logic into `lib.rs` for better testability; `main.rs` is now a thin entrypoint
|
||||||
|
|
||||||
|
## [0.1.1] - 2026-02-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Removed personal path references from default configuration and examples
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-02-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial release of tmuxido
|
||||||
|
|
||||||
605
Cargo.lock
generated
605
Cargo.lock
generated
@ -34,35 +34,56 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstyle-query"
|
name = "anstyle-query"
|
||||||
version = "1.1.4"
|
version = "1.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2"
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstyle-wincon"
|
name = "anstyle-wincon"
|
||||||
version = "3.0.10"
|
version = "3.0.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a"
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"once_cell_polyfill",
|
"once_cell_polyfill",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.100"
|
version = "1.0.102"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "approx"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "autocfg"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.10.0"
|
version = "2.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "by_address"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
@ -72,9 +93,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.5.50"
|
version = "4.5.60"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c2cfd7bf8a6017ddaa4e32ffe7403d547790db06bd171c1c53926faab501623"
|
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap_builder",
|
"clap_builder",
|
||||||
"clap_derive",
|
"clap_derive",
|
||||||
@ -82,9 +103,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_builder"
|
name = "clap_builder"
|
||||||
version = "4.5.50"
|
version = "4.5.60"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0a4c05b9e80c5ccd3a7ef080ad7b6ba7d6fc00a985b8b157197075677c82c7a0"
|
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
@ -94,9 +115,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_derive"
|
name = "clap_derive"
|
||||||
version = "4.5.49"
|
version = "4.5.55"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
|
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@ -106,9 +127,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_lex"
|
name = "clap_lex"
|
||||||
version = "0.7.6"
|
version = "1.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
@ -116,6 +137,64 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "convert_case"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-segmentation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossterm"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"crossterm_winapi",
|
||||||
|
"derive_more",
|
||||||
|
"document-features",
|
||||||
|
"mio",
|
||||||
|
"parking_lot",
|
||||||
|
"rustix",
|
||||||
|
"signal-hook",
|
||||||
|
"signal-hook-mio",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossterm_winapi"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_more"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
|
||||||
|
dependencies = [
|
||||||
|
"derive_more-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_more-impl"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||||
|
dependencies = [
|
||||||
|
"convert_case",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustc_version",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dirs"
|
name = "dirs"
|
||||||
version = "5.0.1"
|
version = "5.0.1"
|
||||||
@ -158,6 +237,15 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "document-features"
|
||||||
|
version = "0.2.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||||
|
dependencies = [
|
||||||
|
"litrs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@ -174,6 +262,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fast-srgb8"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
@ -188,9 +282,9 @@ checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.16"
|
version = "0.2.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
@ -199,9 +293,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
|
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
@ -221,9 +315,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.16.0"
|
version = "0.16.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
|
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
@ -239,12 +333,12 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.12.0"
|
version = "2.13.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f"
|
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"equivalent",
|
"equivalent",
|
||||||
"hashbrown 0.16.0",
|
"hashbrown 0.16.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
@ -257,9 +351,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.15"
|
version = "1.0.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "leb128fmt"
|
name = "leb128fmt"
|
||||||
@ -269,25 +363,51 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.177"
|
version = "0.2.182"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
|
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.10"
|
version = "0.1.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
|
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.11.0"
|
version = "0.12.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lipgloss"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12c1d116ae421d84dfea8bacb5d5fcce330d8b3f03a4867cd1e4860eecd94fb4"
|
||||||
|
dependencies = [
|
||||||
|
"crossterm",
|
||||||
|
"palette",
|
||||||
|
"strip-ansi-escapes",
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "litrs"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lock_api"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||||
|
dependencies = [
|
||||||
|
"scopeguard",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
@ -297,9 +417,30 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.7.6"
|
version = "2.8.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"wasi",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-traits"
|
||||||
|
version = "0.2.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
@ -319,6 +460,95 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "palette"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6"
|
||||||
|
dependencies = [
|
||||||
|
"approx",
|
||||||
|
"fast-srgb8",
|
||||||
|
"palette_derive",
|
||||||
|
"phf",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "palette_derive"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30"
|
||||||
|
dependencies = [
|
||||||
|
"by_address",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking_lot"
|
||||||
|
version = "0.12.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||||
|
dependencies = [
|
||||||
|
"lock_api",
|
||||||
|
"parking_lot_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking_lot_core"
|
||||||
|
version = "0.9.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"redox_syscall",
|
||||||
|
"smallvec",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||||
|
dependencies = [
|
||||||
|
"phf_macros",
|
||||||
|
"phf_shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_generator"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||||
|
dependencies = [
|
||||||
|
"phf_shared",
|
||||||
|
"rand",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_macros"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||||
|
dependencies = [
|
||||||
|
"phf_generator",
|
||||||
|
"phf_shared",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_shared"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||||
|
dependencies = [
|
||||||
|
"siphasher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prettyplease"
|
name = "prettyplease"
|
||||||
version = "0.2.37"
|
version = "0.2.37"
|
||||||
@ -331,27 +561,51 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.103"
|
version = "1.0.106"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
|
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.41"
|
version = "1.0.45"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
|
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "r-efi"
|
name = "r-efi"
|
||||||
version = "5.3.0"
|
version = "6.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.8.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_syscall"
|
||||||
|
version = "0.5.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_users"
|
name = "redox_users"
|
||||||
@ -359,7 +613,7 @@ version = "0.4.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.2.16",
|
"getrandom 0.2.17",
|
||||||
"libredox",
|
"libredox",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
@ -370,16 +624,25 @@ version = "0.5.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.2.16",
|
"getrandom 0.2.17",
|
||||||
"libredox",
|
"libredox",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc_version"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||||
|
dependencies = [
|
||||||
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.3"
|
version = "1.1.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"errno",
|
"errno",
|
||||||
@ -388,12 +651,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ryu"
|
|
||||||
version = "1.0.20"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "same-file"
|
name = "same-file"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@ -403,6 +660,12 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scopeguard"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "semver"
|
name = "semver"
|
||||||
version = "1.0.27"
|
version = "1.0.27"
|
||||||
@ -441,15 +704,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.145"
|
version = "1.0.149"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
|
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"itoa",
|
"itoa",
|
||||||
"memchr",
|
"memchr",
|
||||||
"ryu",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -463,13 +726,65 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shellexpand"
|
name = "shellexpand"
|
||||||
version = "3.1.1"
|
version = "3.1.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb"
|
checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs 6.0.0",
|
"dirs 6.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook"
|
||||||
|
version = "0.3.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"signal-hook-registry",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-mio"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"mio",
|
||||||
|
"signal-hook",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-registry"
|
||||||
|
version = "1.4.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||||
|
dependencies = [
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "siphasher"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smallvec"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strip-ansi-escapes"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025"
|
||||||
|
dependencies = [
|
||||||
|
"vte",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@ -478,9 +793,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.108"
|
version = "2.0.117"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917"
|
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@ -489,12 +804,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.25.0"
|
version = "3.26.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
|
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.4.1",
|
"getrandom 0.4.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@ -511,11 +826,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.17"
|
version = "2.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
|
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl 2.0.17",
|
"thiserror-impl 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -531,9 +846,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror-impl"
|
name = "thiserror-impl"
|
||||||
version = "2.0.17"
|
version = "2.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
|
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@ -542,11 +857,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tmuxido"
|
name = "tmuxido"
|
||||||
version = "0.2.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
|
"lipgloss",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"shellexpand",
|
"shellexpand",
|
||||||
@ -598,9 +914,21 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.20"
|
version = "1.0.24"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-segmentation"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
@ -614,6 +942,15 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vte"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "walkdir"
|
name = "walkdir"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@ -682,6 +1019,22 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-i686-pc-windows-gnu",
|
||||||
|
"winapi-x86_64-pc-windows-gnu",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-i686-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi-util"
|
name = "winapi-util"
|
||||||
version = "0.1.11"
|
version = "0.1.11"
|
||||||
@ -691,6 +1044,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@ -703,16 +1062,7 @@ version = "0.48.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-targets 0.48.5",
|
"windows-targets",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-sys"
|
|
||||||
version = "0.60.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
|
||||||
dependencies = [
|
|
||||||
"windows-targets 0.53.5",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -730,30 +1080,13 @@ version = "0.48.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows_aarch64_gnullvm 0.48.5",
|
"windows_aarch64_gnullvm",
|
||||||
"windows_aarch64_msvc 0.48.5",
|
"windows_aarch64_msvc",
|
||||||
"windows_i686_gnu 0.48.5",
|
"windows_i686_gnu",
|
||||||
"windows_i686_msvc 0.48.5",
|
"windows_i686_msvc",
|
||||||
"windows_x86_64_gnu 0.48.5",
|
"windows_x86_64_gnu",
|
||||||
"windows_x86_64_gnullvm 0.48.5",
|
"windows_x86_64_gnullvm",
|
||||||
"windows_x86_64_msvc 0.48.5",
|
"windows_x86_64_msvc",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-targets"
|
|
||||||
version = "0.53.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
|
||||||
dependencies = [
|
|
||||||
"windows-link",
|
|
||||||
"windows_aarch64_gnullvm 0.53.1",
|
|
||||||
"windows_aarch64_msvc 0.53.1",
|
|
||||||
"windows_i686_gnu 0.53.1",
|
|
||||||
"windows_i686_gnullvm",
|
|
||||||
"windows_i686_msvc 0.53.1",
|
|
||||||
"windows_x86_64_gnu 0.53.1",
|
|
||||||
"windows_x86_64_gnullvm 0.53.1",
|
|
||||||
"windows_x86_64_msvc 0.53.1",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -762,95 +1095,47 @@ version = "0.48.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_aarch64_gnullvm"
|
|
||||||
version = "0.53.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_aarch64_msvc"
|
|
||||||
version = "0.53.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_i686_gnu"
|
|
||||||
version = "0.53.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_i686_gnullvm"
|
|
||||||
version = "0.53.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_i686_msvc"
|
|
||||||
version = "0.53.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_x86_64_gnu"
|
|
||||||
version = "0.53.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_x86_64_gnullvm"
|
|
||||||
version = "0.53.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_x86_64_msvc"
|
|
||||||
version = "0.53.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "0.7.13"
|
version = "0.7.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
|
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
@ -942,3 +1227,9 @@ dependencies = [
|
|||||||
"unicode-xid",
|
"unicode-xid",
|
||||||
"wasmparser",
|
"wasmparser",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tmuxido"
|
name = "tmuxido"
|
||||||
version = "0.2.1"
|
version = "0.10.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
@ -15,3 +15,4 @@ walkdir = "2.4"
|
|||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
shellexpand = "3.1"
|
shellexpand = "3.1"
|
||||||
clap = { version = "4.5", features = ["derive"] }
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
lipgloss = "0.1"
|
||||||
|
|||||||
109
README.md
109
README.md
@ -1,3 +1,15 @@
|
|||||||
|
<div align="center">
|
||||||
|
<img src="docs/assets/tmuxido-logo.png" alt="tmuxido logo" width="200"/>
|
||||||
|
</div>
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://drone.cincoeuzebio.com/cinco/Tmuxido)
|
||||||
|
[](https://drone.cincoeuzebio.com/cinco/Tmuxido)
|
||||||
|
[](https://git.cincoeuzebio.com/cinco/Tmuxido/releases)
|
||||||
|

|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
# tmuxido
|
# tmuxido
|
||||||
|
|
||||||
A Rust-based tool to quickly find and open projects in tmux using fzf. No external dependencies except tmux and fzf!
|
A Rust-based tool to quickly find and open projects in tmux using fzf. No external dependencies except tmux and fzf!
|
||||||
@ -5,19 +17,21 @@ A Rust-based tool to quickly find and open projects in tmux using fzf. No extern
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Search for git repositories in configurable paths
|
- Search for git repositories in configurable paths
|
||||||
- Interactive selection using fzf
|
- Interactive selection using fzf with live `README.md` preview (rendered via `glow` when available)
|
||||||
- Native tmux session creation (no tmuxinator required!)
|
- Native tmux session creation (no tmuxinator required!)
|
||||||
- Support for project-specific `.tmuxido.toml` configs
|
- Support for project-specific `.tmuxido.toml` configs
|
||||||
- Smart session switching (reuses existing sessions)
|
- Smart session switching (reuses existing sessions)
|
||||||
- TOML-based configuration
|
- TOML-based configuration
|
||||||
- Smart caching system for fast subsequent runs
|
- Smart caching system for fast subsequent runs
|
||||||
- Configurable cache TTL
|
- Configurable cache TTL
|
||||||
|
- Self-update capability (`tmuxido --update`)
|
||||||
|
- Keyboard shortcut setup for Hyprland, GNOME, and KDE (`tmuxido --setup-shortcut`)
|
||||||
- Zero external dependencies (except tmux and fzf)
|
- Zero external dependencies (except tmux and fzf)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -fsSL https://git.cincoeuzebio.com/cinco/Tmuxido/raw/branch/main/install.sh | sh
|
curl -fsSL https://raw.githubusercontent.com/cinco/tmuxido/main/install.sh | sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Installs the latest release binary to `~/.local/bin/tmuxido`. On first run, the config file is created automatically at `~/.config/tmuxido/tmuxido.toml`.
|
Installs the latest release binary to `~/.local/bin/tmuxido`. On first run, the config file is created automatically at `~/.config/tmuxido/tmuxido.toml`.
|
||||||
@ -88,6 +102,23 @@ Check cache status:
|
|||||||
tmuxido --cache-status
|
tmuxido --cache-status
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Update tmuxido to the latest version:
|
||||||
|
```bash
|
||||||
|
tmuxido --update
|
||||||
|
```
|
||||||
|
|
||||||
|
Set up a desktop keyboard shortcut (Hyprland, GNOME, KDE):
|
||||||
|
```bash
|
||||||
|
tmuxido --setup-shortcut
|
||||||
|
```
|
||||||
|
|
||||||
|
Install the `.desktop` entry and icon (so tmuxido appears in app launchers like Walker/Rofi):
|
||||||
|
```bash
|
||||||
|
tmuxido --setup-desktop-shortcut
|
||||||
|
```
|
||||||
|
|
||||||
|
Both are also offered automatically on first run. Re-run them any time to reconfigure.
|
||||||
|
|
||||||
View help:
|
View help:
|
||||||
```bash
|
```bash
|
||||||
tmuxido --help
|
tmuxido --help
|
||||||
@ -105,7 +136,7 @@ tmuxido --help
|
|||||||
3. Presents them using fzf for selection
|
3. Presents them using fzf for selection
|
||||||
4. Creates or switches to a tmux session for the selected project
|
4. Creates or switches to a tmux session for the selected project
|
||||||
5. If a `.tmuxido.toml` config exists in the project, uses it to set up custom windows and panes
|
5. If a `.tmuxido.toml` config exists in the project, uses it to set up custom windows and panes
|
||||||
6. Otherwise, creates a default session with two windows: "editor" and "terminal"
|
6. Otherwise, uses the default session config from `~/.config/tmuxido/tmuxido.toml` (configured interactively on first run)
|
||||||
|
|
||||||
## Caching
|
## Caching
|
||||||
|
|
||||||
@ -140,11 +171,61 @@ panes = []
|
|||||||
|
|
||||||
### Available Layouts
|
### Available Layouts
|
||||||
|
|
||||||
- `main-horizontal` - Main pane on top, others below
|
**`main-horizontal`** — Main pane on top, others below
|
||||||
- `main-vertical` - Main pane on left, others on right
|
|
||||||
- `tiled` - All panes tiled
|
```
|
||||||
- `even-horizontal` - All panes in horizontal row
|
┌──────────────────────┐
|
||||||
- `even-vertical` - All panes in vertical column
|
│ │
|
||||||
|
│ main pane │
|
||||||
|
│ │
|
||||||
|
├──────────┬───────────┤
|
||||||
|
│ pane 2 │ pane 3 │
|
||||||
|
└──────────┴───────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**`main-vertical`** — Main pane on left, others on right
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┬────────┐
|
||||||
|
│ │ pane 2 │
|
||||||
|
│ main pane ├────────┤
|
||||||
|
│ │ pane 3 │
|
||||||
|
│ ├────────┤
|
||||||
|
│ │ pane 4 │
|
||||||
|
└─────────────┴────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**`tiled`** — All panes tiled equally
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────┬──────────┐
|
||||||
|
│ pane 1 │ pane 2 │
|
||||||
|
├───────────┼──────────┤
|
||||||
|
│ pane 3 │ pane 4 │
|
||||||
|
└───────────┴──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**`even-horizontal`** — All panes side by side
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────┬────────┬────────┐
|
||||||
|
│ │ │ │
|
||||||
|
│ pane 1 │ pane 2 │ pane 3 │
|
||||||
|
│ │ │ │
|
||||||
|
└────────┴────────┴────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**`even-vertical`** — All panes stacked
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ pane 1 │
|
||||||
|
├──────────────────────┤
|
||||||
|
│ pane 2 │
|
||||||
|
├──────────────────────┤
|
||||||
|
│ pane 3 │
|
||||||
|
└──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
### Panes
|
### Panes
|
||||||
|
|
||||||
@ -152,3 +233,15 @@ Each window can have multiple panes with commands that run automatically:
|
|||||||
- First pane is the main window pane
|
- First pane is the main window pane
|
||||||
- Additional panes are created by splitting
|
- Additional panes are created by splitting
|
||||||
- Empty panes array = just open the window in the project directory
|
- Empty panes array = just open the window in the project directory
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<a href="https://github.com/cinco">
|
||||||
|
<img src="https://github.com/cinco.png" width="100" height="100" style="border-radius: 50%;" alt="Cinco avatar"/>
|
||||||
|
</a>
|
||||||
|
<br><br>
|
||||||
|
<strong>Cinco</strong>
|
||||||
|
<br>
|
||||||
|
<a href="https://github.com/cinco">@cinco</a>
|
||||||
|
</div>
|
||||||
|
|||||||
BIN
docs/assets/tmuxido-icon.png
Normal file
BIN
docs/assets/tmuxido-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 490 KiB |
BIN
docs/assets/tmuxido-icon_16.png
Normal file
BIN
docs/assets/tmuxido-icon_16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
BIN
docs/assets/tmuxido-icon_32.png
Normal file
BIN
docs/assets/tmuxido-icon_32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
BIN
docs/assets/tmuxido-icon_48.png
Normal file
BIN
docs/assets/tmuxido-icon_48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
BIN
docs/assets/tmuxido-icon_96.png
Normal file
BIN
docs/assets/tmuxido-icon_96.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
docs/assets/tmuxido-logo.png
Normal file
BIN
docs/assets/tmuxido-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
46
install.sh
46
install.sh
@ -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."
|
||||||
|
|||||||
317
src/config.rs
317
src/config.rs
@ -4,6 +4,7 @@ use std::fs;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::session::SessionConfig;
|
use crate::session::SessionConfig;
|
||||||
|
use crate::ui;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
@ -14,6 +15,8 @@ pub struct Config {
|
|||||||
pub cache_enabled: bool,
|
pub cache_enabled: bool,
|
||||||
#[serde(default = "default_cache_ttl_hours")]
|
#[serde(default = "default_cache_ttl_hours")]
|
||||||
pub cache_ttl_hours: u64,
|
pub cache_ttl_hours: u64,
|
||||||
|
#[serde(default = "default_update_check_interval_hours")]
|
||||||
|
pub update_check_interval_hours: u64,
|
||||||
#[serde(default = "default_session_config")]
|
#[serde(default = "default_session_config")]
|
||||||
pub default_session: SessionConfig,
|
pub default_session: SessionConfig,
|
||||||
}
|
}
|
||||||
@ -30,6 +33,10 @@ fn default_cache_ttl_hours() -> u64 {
|
|||||||
24
|
24
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_update_check_interval_hours() -> u64 {
|
||||||
|
24
|
||||||
|
}
|
||||||
|
|
||||||
fn default_session_config() -> SessionConfig {
|
fn default_session_config() -> SessionConfig {
|
||||||
use crate::session::Window;
|
use crate::session::Window;
|
||||||
|
|
||||||
@ -89,20 +96,167 @@ impl Config {
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let default_config = Self::default_config();
|
// Ask whether to run the interactive wizard or apply sensible defaults
|
||||||
let toml_string = toml::to_string_pretty(&default_config)
|
let raw = ui::render_setup_choice_prompt()?;
|
||||||
.context("Failed to serialize default config")?;
|
match ui::parse_setup_choice_input(&raw) {
|
||||||
|
ui::SetupChoice::Default => {
|
||||||
|
Self::write_default_config(&config_path)?;
|
||||||
|
ui::render_default_config_saved(&config_path.display().to_string());
|
||||||
|
}
|
||||||
|
ui::SetupChoice::Wizard => {
|
||||||
|
Self::run_wizard(&config_path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fs::write(&config_path, toml_string).with_context(|| {
|
// Offer shortcut and desktop integration regardless of setup mode
|
||||||
format!("Failed to write config file: {}", config_path.display())
|
if let Err(e) = crate::shortcut::setup_shortcut_wizard() {
|
||||||
})?;
|
eprintln!("Warning: shortcut setup failed: {}", e);
|
||||||
|
}
|
||||||
eprintln!("Created default config at: {}", config_path.display());
|
if let Err(e) = crate::shortcut::setup_desktop_integration_wizard() {
|
||||||
|
eprintln!("Warning: desktop integration failed: {}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(config_path)
|
Ok(config_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write the built-in default config to `config_path` without any prompts.
|
||||||
|
fn write_default_config(config_path: &std::path::Path) -> Result<()> {
|
||||||
|
let config = Self::default_config();
|
||||||
|
let toml_string = toml::to_string_pretty(&config).context("Failed to serialize config")?;
|
||||||
|
fs::write(config_path, toml_string)
|
||||||
|
.with_context(|| format!("Failed to write config file: {}", config_path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the full interactive configuration wizard and offer shortcut / desktop setup at the end.
|
||||||
|
fn run_wizard(config_path: &std::path::Path) -> Result<()> {
|
||||||
|
let paths = Self::prompt_for_paths()?;
|
||||||
|
let max_depth = Self::prompt_for_max_depth()?;
|
||||||
|
let cache_enabled = Self::prompt_for_cache_enabled()?;
|
||||||
|
let cache_ttl_hours = if cache_enabled {
|
||||||
|
Self::prompt_for_cache_ttl()?
|
||||||
|
} else {
|
||||||
|
24
|
||||||
|
};
|
||||||
|
let windows = Self::prompt_for_windows()?;
|
||||||
|
|
||||||
|
// Render styled success message before moving windows
|
||||||
|
ui::render_config_created(&paths, max_depth, cache_enabled, cache_ttl_hours, &windows);
|
||||||
|
|
||||||
|
let config = Config {
|
||||||
|
paths,
|
||||||
|
max_depth,
|
||||||
|
cache_enabled,
|
||||||
|
cache_ttl_hours,
|
||||||
|
update_check_interval_hours: default_update_check_interval_hours(),
|
||||||
|
default_session: SessionConfig { windows },
|
||||||
|
};
|
||||||
|
|
||||||
|
let toml_string = toml::to_string_pretty(&config).context("Failed to serialize config")?;
|
||||||
|
|
||||||
|
fs::write(config_path, toml_string)
|
||||||
|
.with_context(|| format!("Failed to write config file: {}", config_path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_for_paths() -> Result<Vec<String>> {
|
||||||
|
// Render styled welcome banner
|
||||||
|
ui::render_welcome_banner();
|
||||||
|
|
||||||
|
// Get input with styled prompt
|
||||||
|
let input = ui::render_paths_prompt()?;
|
||||||
|
let paths = Self::parse_paths_input(&input);
|
||||||
|
|
||||||
|
if paths.is_empty() {
|
||||||
|
ui::render_fallback_message();
|
||||||
|
Ok(vec![
|
||||||
|
dirs::home_dir()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.join("Projects")
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string(),
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
Ok(paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_for_max_depth() -> Result<usize> {
|
||||||
|
ui::render_section_header("Scan Settings");
|
||||||
|
let input = ui::render_max_depth_prompt()?;
|
||||||
|
Ok(ui::parse_max_depth_input(&input).unwrap_or(5))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_for_cache_enabled() -> Result<bool> {
|
||||||
|
ui::render_section_header("Cache Settings");
|
||||||
|
let input = ui::render_cache_enabled_prompt()?;
|
||||||
|
Ok(ui::parse_cache_enabled_input(&input).unwrap_or(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_for_cache_ttl() -> Result<u64> {
|
||||||
|
let input = ui::render_cache_ttl_prompt()?;
|
||||||
|
Ok(ui::parse_cache_ttl_input(&input).unwrap_or(24))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_for_windows() -> Result<Vec<crate::session::Window>> {
|
||||||
|
ui::render_section_header("Default Session");
|
||||||
|
let input = ui::render_windows_prompt()?;
|
||||||
|
|
||||||
|
let window_names = ui::parse_comma_separated_list(&input);
|
||||||
|
|
||||||
|
let names = if window_names.is_empty() {
|
||||||
|
vec!["editor".to_string(), "terminal".to_string()]
|
||||||
|
} else {
|
||||||
|
window_names
|
||||||
|
};
|
||||||
|
|
||||||
|
// Configure panes and layout for each window
|
||||||
|
let mut windows = Vec::new();
|
||||||
|
for name in names {
|
||||||
|
let panes = Self::prompt_for_panes(&name)?;
|
||||||
|
let layout = if panes.len() > 1 {
|
||||||
|
ui::render_layout_prompt(&name, panes.len())?
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
windows.push(crate::session::Window {
|
||||||
|
name,
|
||||||
|
panes,
|
||||||
|
layout,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(windows)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_for_panes(window_name: &str) -> Result<Vec<String>> {
|
||||||
|
let input = ui::render_panes_prompt(window_name)?;
|
||||||
|
|
||||||
|
let pane_names = ui::parse_comma_separated_list(&input);
|
||||||
|
|
||||||
|
if pane_names.is_empty() {
|
||||||
|
// Single pane, no commands
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask for commands for each pane
|
||||||
|
let mut panes = Vec::new();
|
||||||
|
for pane_name in pane_names {
|
||||||
|
let command = ui::render_pane_command_prompt(&pane_name)?;
|
||||||
|
panes.push(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(panes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_paths_input(input: &str) -> Vec<String> {
|
||||||
|
input
|
||||||
|
.trim()
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn default_config() -> Self {
|
fn default_config() -> Self {
|
||||||
Config {
|
Config {
|
||||||
paths: vec![
|
paths: vec![
|
||||||
@ -115,6 +269,7 @@ impl Config {
|
|||||||
max_depth: 5,
|
max_depth: 5,
|
||||||
cache_enabled: true,
|
cache_enabled: true,
|
||||||
cache_ttl_hours: 24,
|
cache_ttl_hours: 24,
|
||||||
|
update_check_interval_hours: default_update_check_interval_hours(),
|
||||||
default_session: default_session_config(),
|
default_session: default_session_config(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -131,6 +286,7 @@ mod tests {
|
|||||||
assert_eq!(config.max_depth, 5);
|
assert_eq!(config.max_depth, 5);
|
||||||
assert!(config.cache_enabled);
|
assert!(config.cache_enabled);
|
||||||
assert_eq!(config.cache_ttl_hours, 24);
|
assert_eq!(config.cache_ttl_hours, 24);
|
||||||
|
assert_eq!(config.update_check_interval_hours, 24);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -153,4 +309,149 @@ mod tests {
|
|||||||
let result: Result<Config, _> = toml::from_str("not valid toml ]][[");
|
let result: Result<Config, _> = toml::from_str("not valid toml ]][[");
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_parse_single_path() {
|
||||||
|
let input = "~/Projects";
|
||||||
|
let paths = Config::parse_paths_input(input);
|
||||||
|
assert_eq!(paths, vec!["~/Projects"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_parse_multiple_paths_with_commas() {
|
||||||
|
let input = "~/Projects, ~/work, ~/repos";
|
||||||
|
let paths = Config::parse_paths_input(input);
|
||||||
|
assert_eq!(paths, vec!["~/Projects", "~/work", "~/repos"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_trim_whitespace_from_paths() {
|
||||||
|
let input = " ~/Projects , ~/work ";
|
||||||
|
let paths = Config::parse_paths_input(input);
|
||||||
|
assert_eq!(paths, vec!["~/Projects", "~/work"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_empty_vec_for_empty_input() {
|
||||||
|
let input = "";
|
||||||
|
let paths = Config::parse_paths_input(input);
|
||||||
|
assert!(paths.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_empty_vec_for_whitespace_only() {
|
||||||
|
let input = " ";
|
||||||
|
let paths = Config::parse_paths_input(input);
|
||||||
|
assert!(paths.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_handle_empty_parts_between_commas() {
|
||||||
|
let input = "~/Projects,,~/work";
|
||||||
|
let paths = Config::parse_paths_input(input);
|
||||||
|
assert_eq!(paths, vec!["~/Projects", "~/work"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_use_ui_parse_functions_for_max_depth() {
|
||||||
|
// Test that our UI parsing produces expected results
|
||||||
|
assert_eq!(ui::parse_max_depth_input(""), None);
|
||||||
|
assert_eq!(ui::parse_max_depth_input("5"), Some(5));
|
||||||
|
assert_eq!(ui::parse_max_depth_input("invalid"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_use_ui_parse_functions_for_cache_enabled() {
|
||||||
|
assert_eq!(ui::parse_cache_enabled_input(""), None);
|
||||||
|
assert_eq!(ui::parse_cache_enabled_input("y"), Some(true));
|
||||||
|
assert_eq!(ui::parse_cache_enabled_input("n"), Some(false));
|
||||||
|
assert_eq!(ui::parse_cache_enabled_input("maybe"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_use_ui_parse_functions_for_cache_ttl() {
|
||||||
|
assert_eq!(ui::parse_cache_ttl_input(""), None);
|
||||||
|
assert_eq!(ui::parse_cache_ttl_input("24"), Some(24));
|
||||||
|
assert_eq!(ui::parse_cache_ttl_input("invalid"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_use_ui_parse_functions_for_window_names() {
|
||||||
|
let result = ui::parse_comma_separated_list("editor, terminal, server");
|
||||||
|
assert_eq!(result, vec!["editor", "terminal", "server"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_use_ui_parse_functions_for_layout() {
|
||||||
|
assert_eq!(ui::parse_layout_input(""), None);
|
||||||
|
assert_eq!(
|
||||||
|
ui::parse_layout_input("1"),
|
||||||
|
Some("main-horizontal".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ui::parse_layout_input("main-vertical"),
|
||||||
|
Some("main-vertical".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(ui::parse_layout_input("invalid"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_write_default_config_to_file() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let config_path = dir.path().join("tmuxido.toml");
|
||||||
|
|
||||||
|
Config::write_default_config(&config_path).unwrap();
|
||||||
|
|
||||||
|
assert!(config_path.exists());
|
||||||
|
let content = std::fs::read_to_string(&config_path).unwrap();
|
||||||
|
let loaded: Config = toml::from_str(&content).unwrap();
|
||||||
|
assert!(!loaded.paths.is_empty());
|
||||||
|
assert_eq!(loaded.max_depth, 5);
|
||||||
|
assert!(loaded.cache_enabled);
|
||||||
|
assert_eq!(loaded.cache_ttl_hours, 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_write_valid_toml_in_default_config() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let config_path = dir.path().join("tmuxido.toml");
|
||||||
|
|
||||||
|
Config::write_default_config(&config_path).unwrap();
|
||||||
|
|
||||||
|
let content = std::fs::read_to_string(&config_path).unwrap();
|
||||||
|
// Must parse cleanly
|
||||||
|
let result: Result<Config, _> = toml::from_str(&content);
|
||||||
|
assert!(result.is_ok(), "Default config must be valid TOML");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_parse_config_with_windows_and_panes() {
|
||||||
|
let toml_str = r#"
|
||||||
|
paths = ["/projects"]
|
||||||
|
max_depth = 3
|
||||||
|
cache_enabled = true
|
||||||
|
cache_ttl_hours = 12
|
||||||
|
|
||||||
|
[default_session]
|
||||||
|
[[default_session.windows]]
|
||||||
|
name = "editor"
|
||||||
|
panes = ["nvim .", "git status"]
|
||||||
|
|
||||||
|
[[default_session.windows]]
|
||||||
|
name = "terminal"
|
||||||
|
panes = []
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
assert_eq!(config.paths, vec!["/projects"]);
|
||||||
|
assert_eq!(config.max_depth, 3);
|
||||||
|
assert!(config.cache_enabled);
|
||||||
|
assert_eq!(config.cache_ttl_hours, 12);
|
||||||
|
assert_eq!(config.default_session.windows.len(), 2);
|
||||||
|
assert_eq!(config.default_session.windows[0].name, "editor");
|
||||||
|
assert_eq!(config.default_session.windows[0].panes.len(), 2);
|
||||||
|
assert_eq!(config.default_session.windows[0].panes[0], "nvim .");
|
||||||
|
assert_eq!(config.default_session.windows[0].panes[1], "git status");
|
||||||
|
assert_eq!(config.default_session.windows[1].name, "terminal");
|
||||||
|
assert!(config.default_session.windows[1].panes.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
425
src/deps.rs
Normal file
425
src/deps.rs
Normal file
@ -0,0 +1,425 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::io::{self, Write};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
/// Required external tool dependencies.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum Dep {
|
||||||
|
Fzf,
|
||||||
|
Tmux,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supported Linux package managers.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum PackageManager {
|
||||||
|
Apt,
|
||||||
|
Pacman,
|
||||||
|
Dnf,
|
||||||
|
Yum,
|
||||||
|
Zypper,
|
||||||
|
Emerge,
|
||||||
|
Xbps,
|
||||||
|
Apk,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Injectable binary availability checker — enables unit testing without hitting the real system.
|
||||||
|
pub trait BinaryChecker {
|
||||||
|
fn is_available(&self, name: &str) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Production implementation: delegates to the system `which` command.
|
||||||
|
pub struct SystemBinaryChecker;
|
||||||
|
|
||||||
|
impl BinaryChecker for SystemBinaryChecker {
|
||||||
|
fn is_available(&self, name: &str) -> bool {
|
||||||
|
Command::new("which")
|
||||||
|
.arg(name)
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dep {
|
||||||
|
pub fn all() -> Vec<Self> {
|
||||||
|
vec![Self::Fzf, Self::Tmux]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn binary_name(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Fzf => "fzf",
|
||||||
|
Self::Tmux => "tmux",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn package_name(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Fzf => "fzf",
|
||||||
|
Self::Tmux => "tmux",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PackageManager {
|
||||||
|
/// Ordered list for detection — more specific managers first.
|
||||||
|
pub fn all_ordered() -> Vec<Self> {
|
||||||
|
vec![
|
||||||
|
Self::Apt,
|
||||||
|
Self::Pacman,
|
||||||
|
Self::Dnf,
|
||||||
|
Self::Yum,
|
||||||
|
Self::Zypper,
|
||||||
|
Self::Emerge,
|
||||||
|
Self::Xbps,
|
||||||
|
Self::Apk,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Binary used to detect whether this package manager is installed.
|
||||||
|
pub fn detection_binary(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Apt => "apt",
|
||||||
|
Self::Pacman => "pacman",
|
||||||
|
Self::Dnf => "dnf",
|
||||||
|
Self::Yum => "yum",
|
||||||
|
Self::Zypper => "zypper",
|
||||||
|
Self::Emerge => "emerge",
|
||||||
|
Self::Xbps => "xbps-install",
|
||||||
|
Self::Apk => "apk",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn display_name(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Apt => "apt (Debian/Ubuntu)",
|
||||||
|
Self::Pacman => "pacman (Arch Linux)",
|
||||||
|
Self::Dnf => "dnf (Fedora)",
|
||||||
|
Self::Yum => "yum (RHEL/CentOS)",
|
||||||
|
Self::Zypper => "zypper (openSUSE)",
|
||||||
|
Self::Emerge => "emerge (Gentoo)",
|
||||||
|
Self::Xbps => "xbps-install (Void Linux)",
|
||||||
|
Self::Apk => "apk (Alpine Linux)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the full install command (including `sudo`) for the given packages.
|
||||||
|
pub fn install_command(&self, packages: &[&str]) -> Vec<String> {
|
||||||
|
let mut cmd = vec!["sudo".to_string()];
|
||||||
|
match self {
|
||||||
|
Self::Apt => cmd.extend(["apt", "install", "-y"].map(String::from)),
|
||||||
|
Self::Pacman => cmd.extend(["pacman", "-S", "--noconfirm"].map(String::from)),
|
||||||
|
Self::Dnf => cmd.extend(["dnf", "install", "-y"].map(String::from)),
|
||||||
|
Self::Yum => cmd.extend(["yum", "install", "-y"].map(String::from)),
|
||||||
|
Self::Zypper => cmd.extend(["zypper", "install", "-y"].map(String::from)),
|
||||||
|
Self::Emerge => cmd.extend(["emerge"].map(String::from)),
|
||||||
|
Self::Xbps => cmd.extend(["xbps-install", "-y"].map(String::from)),
|
||||||
|
Self::Apk => cmd.extend(["apk", "add"].map(String::from)),
|
||||||
|
}
|
||||||
|
cmd.extend(packages.iter().map(|&s| s.to_string()));
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the required deps that are not currently installed.
|
||||||
|
pub fn check_missing<C: BinaryChecker>(checker: &C) -> Vec<Dep> {
|
||||||
|
Dep::all()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|dep| !checker.is_available(dep.binary_name()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the first supported package manager found on the system.
|
||||||
|
pub fn detect_package_manager<C: BinaryChecker>(checker: &C) -> Option<PackageManager> {
|
||||||
|
PackageManager::all_ordered()
|
||||||
|
.into_iter()
|
||||||
|
.find(|pm| checker.is_available(pm.detection_binary()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks for missing dependencies, informs the user, and offers to install them.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(())` if all deps are available (or successfully installed).
|
||||||
|
pub fn ensure_dependencies() -> Result<()> {
|
||||||
|
let checker = SystemBinaryChecker;
|
||||||
|
let missing = check_missing(&checker);
|
||||||
|
|
||||||
|
if missing.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("The following required tools are not installed:");
|
||||||
|
for dep in &missing {
|
||||||
|
eprintln!(" ✗ {}", dep.binary_name());
|
||||||
|
}
|
||||||
|
eprintln!();
|
||||||
|
|
||||||
|
let pm = detect_package_manager(&checker).ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"No supported package manager found. Please install {} manually.",
|
||||||
|
missing
|
||||||
|
.iter()
|
||||||
|
.map(|d| d.binary_name())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" and ")
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let packages: Vec<&str> = missing.iter().map(|d| d.package_name()).collect();
|
||||||
|
let cmd = pm.install_command(&packages);
|
||||||
|
|
||||||
|
eprintln!("Detected package manager: {}", pm.display_name());
|
||||||
|
eprintln!("Install command: {}", cmd.join(" "));
|
||||||
|
eprint!("\nProceed with installation? [Y/n] ");
|
||||||
|
io::stdout().flush().ok();
|
||||||
|
|
||||||
|
let mut answer = String::new();
|
||||||
|
io::stdin()
|
||||||
|
.read_line(&mut answer)
|
||||||
|
.context("Failed to read user input")?;
|
||||||
|
|
||||||
|
let answer = answer.trim().to_lowercase();
|
||||||
|
if answer == "n" || answer == "no" {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Installation cancelled. Please install {} manually before running tmuxido.",
|
||||||
|
missing
|
||||||
|
.iter()
|
||||||
|
.map(|d| d.binary_name())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" and ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (program, args) = cmd
|
||||||
|
.split_first()
|
||||||
|
.expect("install_command always returns at least one element");
|
||||||
|
|
||||||
|
let status = Command::new(program)
|
||||||
|
.args(args)
|
||||||
|
.status()
|
||||||
|
.with_context(|| format!("Failed to run: {}", cmd.join(" ")))?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Installation failed. Please install {} manually.",
|
||||||
|
missing
|
||||||
|
.iter()
|
||||||
|
.map(|d| d.binary_name())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" and ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("Installation complete!");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
struct MockChecker {
|
||||||
|
available: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockChecker {
|
||||||
|
fn with(available: &[&str]) -> Self {
|
||||||
|
Self {
|
||||||
|
available: available.iter().map(|s| s.to_string()).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BinaryChecker for MockChecker {
|
||||||
|
fn is_available(&self, name: &str) -> bool {
|
||||||
|
self.available.iter().any(|s| s == name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dep ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_fzf_binary_name() {
|
||||||
|
assert_eq!(Dep::Fzf.binary_name(), "fzf");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_tmux_binary_name() {
|
||||||
|
assert_eq!(Dep::Tmux.binary_name(), "tmux");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_include_fzf_and_tmux_in_all_deps() {
|
||||||
|
let deps = Dep::all();
|
||||||
|
assert!(deps.contains(&Dep::Fzf));
|
||||||
|
assert!(deps.contains(&Dep::Tmux));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_same_package_name_as_binary_for_fzf() {
|
||||||
|
assert_eq!(Dep::Fzf.package_name(), "fzf");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_same_package_name_as_binary_for_tmux() {
|
||||||
|
assert_eq!(Dep::Tmux.package_name(), "tmux");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- check_missing ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_empty_when_all_deps_present() {
|
||||||
|
let checker = MockChecker::with(&["fzf", "tmux"]);
|
||||||
|
assert!(check_missing(&checker).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_detect_fzf_as_missing_when_only_tmux_present() {
|
||||||
|
let checker = MockChecker::with(&["tmux"]);
|
||||||
|
let missing = check_missing(&checker);
|
||||||
|
assert_eq!(missing, vec![Dep::Fzf]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_detect_tmux_as_missing_when_only_fzf_present() {
|
||||||
|
let checker = MockChecker::with(&["fzf"]);
|
||||||
|
let missing = check_missing(&checker);
|
||||||
|
assert_eq!(missing, vec![Dep::Tmux]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_detect_both_missing_when_none_present() {
|
||||||
|
let checker = MockChecker::with(&[]);
|
||||||
|
let missing = check_missing(&checker);
|
||||||
|
assert_eq!(missing.len(), 2);
|
||||||
|
assert!(missing.contains(&Dep::Fzf));
|
||||||
|
assert!(missing.contains(&Dep::Tmux));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- detect_package_manager ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_detect_apt_when_available() {
|
||||||
|
let checker = MockChecker::with(&["apt"]);
|
||||||
|
assert_eq!(detect_package_manager(&checker), Some(PackageManager::Apt));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_detect_pacman_when_available() {
|
||||||
|
let checker = MockChecker::with(&["pacman"]);
|
||||||
|
assert_eq!(
|
||||||
|
detect_package_manager(&checker),
|
||||||
|
Some(PackageManager::Pacman)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_detect_dnf_when_available() {
|
||||||
|
let checker = MockChecker::with(&["dnf"]);
|
||||||
|
assert_eq!(detect_package_manager(&checker), Some(PackageManager::Dnf));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_detect_xbps_when_xbps_install_available() {
|
||||||
|
let checker = MockChecker::with(&["xbps-install"]);
|
||||||
|
assert_eq!(detect_package_manager(&checker), Some(PackageManager::Xbps));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_detect_apk_when_available() {
|
||||||
|
let checker = MockChecker::with(&["apk"]);
|
||||||
|
assert_eq!(detect_package_manager(&checker), Some(PackageManager::Apk));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_none_when_no_pm_detected() {
|
||||||
|
let checker = MockChecker::with(&["ls", "sh"]);
|
||||||
|
assert_eq!(detect_package_manager(&checker), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_prefer_apt_over_pacman_when_both_available() {
|
||||||
|
let checker = MockChecker::with(&["apt", "pacman"]);
|
||||||
|
assert_eq!(detect_package_manager(&checker), Some(PackageManager::Apt));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- PackageManager::install_command ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_build_apt_install_command() {
|
||||||
|
let cmd = PackageManager::Apt.install_command(&["fzf", "tmux"]);
|
||||||
|
assert_eq!(cmd, vec!["sudo", "apt", "install", "-y", "fzf", "tmux"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_build_pacman_install_command() {
|
||||||
|
let cmd = PackageManager::Pacman.install_command(&["fzf", "tmux"]);
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
vec!["sudo", "pacman", "-S", "--noconfirm", "fzf", "tmux"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_build_dnf_install_command() {
|
||||||
|
let cmd = PackageManager::Dnf.install_command(&["fzf"]);
|
||||||
|
assert_eq!(cmd, vec!["sudo", "dnf", "install", "-y", "fzf"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_build_yum_install_command() {
|
||||||
|
let cmd = PackageManager::Yum.install_command(&["tmux"]);
|
||||||
|
assert_eq!(cmd, vec!["sudo", "yum", "install", "-y", "tmux"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_build_zypper_install_command() {
|
||||||
|
let cmd = PackageManager::Zypper.install_command(&["fzf", "tmux"]);
|
||||||
|
assert_eq!(cmd, vec!["sudo", "zypper", "install", "-y", "fzf", "tmux"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_build_emerge_install_command() {
|
||||||
|
let cmd = PackageManager::Emerge.install_command(&["fzf"]);
|
||||||
|
assert_eq!(cmd, vec!["sudo", "emerge", "fzf"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_build_xbps_install_command() {
|
||||||
|
let cmd = PackageManager::Xbps.install_command(&["tmux"]);
|
||||||
|
assert_eq!(cmd, vec!["sudo", "xbps-install", "-y", "tmux"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_build_apk_install_command() {
|
||||||
|
let cmd = PackageManager::Apk.install_command(&["fzf", "tmux"]);
|
||||||
|
assert_eq!(cmd, vec!["sudo", "apk", "add", "fzf", "tmux"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_build_command_for_single_package() {
|
||||||
|
let cmd = PackageManager::Apt.install_command(&["fzf"]);
|
||||||
|
assert_eq!(cmd, vec!["sudo", "apt", "install", "-y", "fzf"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_include_sudo_for_all_package_managers() {
|
||||||
|
for pm in PackageManager::all_ordered() {
|
||||||
|
let cmd = pm.install_command(&["fzf"]);
|
||||||
|
assert_eq!(
|
||||||
|
cmd.first().map(String::as_str),
|
||||||
|
Some("sudo"),
|
||||||
|
"{} install command should start with sudo",
|
||||||
|
pm.display_name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_include_all_packages_in_command() {
|
||||||
|
let cmd = PackageManager::Apt.install_command(&["fzf", "tmux", "git"]);
|
||||||
|
assert!(cmd.contains(&"fzf".to_string()));
|
||||||
|
assert!(cmd.contains(&"tmux".to_string()));
|
||||||
|
assert!(cmd.contains(&"git".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
315
src/lib.rs
315
src/lib.rs
@ -1,6 +1,11 @@
|
|||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod deps;
|
||||||
|
pub mod self_update;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
pub mod shortcut;
|
||||||
|
pub mod ui;
|
||||||
|
pub mod update_check;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use cache::ProjectCache;
|
use cache::ProjectCache;
|
||||||
@ -11,6 +16,14 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::time::UNIX_EPOCH;
|
use std::time::UNIX_EPOCH;
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
pub fn setup_shortcut_wizard() -> Result<()> {
|
||||||
|
shortcut::setup_shortcut_wizard()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn setup_desktop_integration_wizard() -> Result<()> {
|
||||||
|
shortcut::setup_desktop_integration_wizard()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn show_cache_status(config: &Config) -> Result<()> {
|
pub fn show_cache_status(config: &Config) -> Result<()> {
|
||||||
if !config.cache_enabled {
|
if !config.cache_enabled {
|
||||||
println!("Cache is disabled in configuration");
|
println!("Cache is disabled in configuration");
|
||||||
@ -36,44 +49,85 @@ pub fn show_cache_status(config: &Config) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_projects(config: &Config, force_refresh: bool) -> Result<Vec<PathBuf>> {
|
pub fn get_projects(config: &Config, force_refresh: bool) -> Result<Vec<PathBuf>> {
|
||||||
if !config.cache_enabled || force_refresh {
|
get_projects_internal(
|
||||||
let (projects, fingerprints) = scan_all_roots(config)?;
|
config,
|
||||||
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
force_refresh,
|
||||||
cache.save()?;
|
&ProjectCache::load,
|
||||||
eprintln!("Cache updated with {} projects", projects.len());
|
&|cache| cache.save(),
|
||||||
return Ok(projects);
|
&scan_all_roots,
|
||||||
|
&spawn_background_refresh,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(mut cache) = ProjectCache::load()? {
|
/// Rebuilds the project cache incrementally. Intended to be called from a
|
||||||
// Cache no formato antigo (sem dir_mtimes) → atualizar com rescan completo
|
/// 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() {
|
if cache.dir_mtimes.is_empty() {
|
||||||
eprintln!("Upgrading cache, scanning for projects...");
|
// Old cache format — full rescan
|
||||||
let (projects, fingerprints) = scan_all_roots(config)?;
|
let (projects, fingerprints) = scan_all_roots(config)?;
|
||||||
let new_cache = ProjectCache::new(projects.clone(), fingerprints);
|
ProjectCache::new(projects, fingerprints).save()?;
|
||||||
new_cache.save()?;
|
} else {
|
||||||
eprintln!("Cache updated with {} projects", projects.len());
|
// Incremental rescan based on directory mtimes
|
||||||
return Ok(projects);
|
|
||||||
}
|
|
||||||
|
|
||||||
let changed = cache.validate_and_update(&|root| scan_from_root(root, config))?;
|
let changed = cache.validate_and_update(&|root| scan_from_root(root, config))?;
|
||||||
if changed {
|
if changed {
|
||||||
cache.save()?;
|
cache.save()?;
|
||||||
eprintln!(
|
}
|
||||||
"Cache updated incrementally ({} projects)",
|
}
|
||||||
cache.projects.len()
|
}
|
||||||
);
|
}
|
||||||
} else {
|
Ok(())
|
||||||
eprintln!("Using cached projects ({} projects)", cache.projects.len());
|
}
|
||||||
|
|
||||||
|
fn spawn_background_refresh() {
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
std::process::Command::new(exe)
|
||||||
|
.arg("--background-refresh")
|
||||||
|
.stdin(std::process::Stdio::null())
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
fn get_projects_internal(
|
||||||
|
config: &Config,
|
||||||
|
force_refresh: bool,
|
||||||
|
cache_loader: &dyn Fn() -> Result<Option<ProjectCache>>,
|
||||||
|
cache_saver: &dyn Fn(&ProjectCache) -> Result<()>,
|
||||||
|
scanner: &dyn Fn(&Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>,
|
||||||
|
refresh_spawner: &dyn Fn(),
|
||||||
|
) -> Result<Vec<PathBuf>> {
|
||||||
|
if !config.cache_enabled || force_refresh {
|
||||||
|
let (projects, fingerprints) = scanner(config)?;
|
||||||
|
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
||||||
|
cache_saver(&cache)?;
|
||||||
|
return Ok(projects);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(cache) = cache_loader()? {
|
||||||
|
// Cache exists — return immediately (stale-while-revalidate).
|
||||||
|
// Spawn a background refresh if the cache is stale or in old format.
|
||||||
|
let is_stale =
|
||||||
|
cache.dir_mtimes.is_empty() || cache.age_in_seconds() > config.cache_ttl_hours * 3600;
|
||||||
|
if is_stale {
|
||||||
|
refresh_spawner();
|
||||||
}
|
}
|
||||||
return Ok(cache.projects);
|
return Ok(cache.projects);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sem cache ainda — scan completo inicial
|
// No cache yet — first run, blocking scan is unavoidable.
|
||||||
eprintln!("No cache found, scanning for projects...");
|
eprintln!("No cache found, scanning for projects...");
|
||||||
let (projects, fingerprints) = scan_all_roots(config)?;
|
let (projects, fingerprints) = scanner(config)?;
|
||||||
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
||||||
cache.save()?;
|
cache_saver(&cache)?;
|
||||||
eprintln!("Cache updated with {} projects", projects.len());
|
|
||||||
Ok(projects)
|
Ok(projects)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,3 +214,214 @@ pub fn launch_tmux_session(selected: &Path, config: &Config) -> Result<()> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
fn make_config(cache_enabled: bool, cache_ttl_hours: u64) -> Config {
|
||||||
|
Config {
|
||||||
|
paths: vec!["/tmp/test".to_string()],
|
||||||
|
max_depth: 3,
|
||||||
|
cache_enabled,
|
||||||
|
cache_ttl_hours,
|
||||||
|
update_check_interval_hours: 24,
|
||||||
|
default_session: session::SessionConfig { windows: vec![] },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fresh_cache(projects: Vec<PathBuf>) -> ProjectCache {
|
||||||
|
let fingerprints = HashMap::from([(PathBuf::from("/tracked"), 1u64)]);
|
||||||
|
ProjectCache::new(projects, fingerprints)
|
||||||
|
// last_updated = now_secs() — within any reasonable TTL
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stale_cache(projects: Vec<PathBuf>) -> ProjectCache {
|
||||||
|
let fingerprints = HashMap::from([(PathBuf::from("/tracked"), 1u64)]);
|
||||||
|
let mut c = ProjectCache::new(projects, fingerprints);
|
||||||
|
c.last_updated = 0; // epoch — always older than TTL
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_internal(
|
||||||
|
config: &Config,
|
||||||
|
force_refresh: bool,
|
||||||
|
cache_loader: &dyn Fn() -> Result<Option<ProjectCache>>,
|
||||||
|
cache_saver: &dyn Fn(&ProjectCache) -> Result<()>,
|
||||||
|
scanner: &dyn Fn(&Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>,
|
||||||
|
refresh_spawner: &dyn Fn(),
|
||||||
|
) -> Result<Vec<PathBuf>> {
|
||||||
|
get_projects_internal(
|
||||||
|
config,
|
||||||
|
force_refresh,
|
||||||
|
cache_loader,
|
||||||
|
cache_saver,
|
||||||
|
scanner,
|
||||||
|
refresh_spawner,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_scan_when_cache_disabled() {
|
||||||
|
let config = make_config(false, 24);
|
||||||
|
let expected = vec![PathBuf::from("/p1")];
|
||||||
|
let scanner_called = RefCell::new(false);
|
||||||
|
let saver_called = RefCell::new(false);
|
||||||
|
let spawner_called = RefCell::new(false);
|
||||||
|
|
||||||
|
let result = call_internal(
|
||||||
|
&config,
|
||||||
|
false,
|
||||||
|
&|| panic!("loader must not be called when cache disabled"),
|
||||||
|
&|_| {
|
||||||
|
*saver_called.borrow_mut() = true;
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
&|_| {
|
||||||
|
*scanner_called.borrow_mut() = true;
|
||||||
|
Ok((expected.clone(), HashMap::new()))
|
||||||
|
},
|
||||||
|
&|| *spawner_called.borrow_mut() = true,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(scanner_called.into_inner());
|
||||||
|
assert!(saver_called.into_inner());
|
||||||
|
assert!(!spawner_called.into_inner());
|
||||||
|
assert_eq!(result.unwrap(), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_scan_when_force_refresh() {
|
||||||
|
let config = make_config(true, 24);
|
||||||
|
let expected = vec![PathBuf::from("/p1")];
|
||||||
|
let scanner_called = RefCell::new(false);
|
||||||
|
let saver_called = RefCell::new(false);
|
||||||
|
let spawner_called = RefCell::new(false);
|
||||||
|
|
||||||
|
let result = call_internal(
|
||||||
|
&config,
|
||||||
|
true,
|
||||||
|
&|| panic!("loader must not be called on force refresh"),
|
||||||
|
&|_| {
|
||||||
|
*saver_called.borrow_mut() = true;
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
&|_| {
|
||||||
|
*scanner_called.borrow_mut() = true;
|
||||||
|
Ok((expected.clone(), HashMap::new()))
|
||||||
|
},
|
||||||
|
&|| *spawner_called.borrow_mut() = true,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(scanner_called.into_inner());
|
||||||
|
assert!(saver_called.into_inner());
|
||||||
|
assert!(!spawner_called.into_inner());
|
||||||
|
assert_eq!(result.unwrap(), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_do_blocking_scan_when_no_cache_exists() {
|
||||||
|
let config = make_config(true, 24);
|
||||||
|
let expected = vec![PathBuf::from("/p1")];
|
||||||
|
let scanner_called = RefCell::new(false);
|
||||||
|
let saver_called = RefCell::new(false);
|
||||||
|
let spawner_called = RefCell::new(false);
|
||||||
|
|
||||||
|
let result = call_internal(
|
||||||
|
&config,
|
||||||
|
false,
|
||||||
|
&|| Ok(None),
|
||||||
|
&|_| {
|
||||||
|
*saver_called.borrow_mut() = true;
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
&|_| {
|
||||||
|
*scanner_called.borrow_mut() = true;
|
||||||
|
Ok((expected.clone(), HashMap::new()))
|
||||||
|
},
|
||||||
|
&|| *spawner_called.borrow_mut() = true,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(scanner_called.into_inner());
|
||||||
|
assert!(saver_called.into_inner());
|
||||||
|
assert!(!spawner_called.into_inner());
|
||||||
|
assert_eq!(result.unwrap(), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_cached_projects_immediately_when_cache_is_fresh() {
|
||||||
|
let config = make_config(true, 24);
|
||||||
|
let cached = vec![PathBuf::from("/cached/project")];
|
||||||
|
let cache = RefCell::new(Some(fresh_cache(cached.clone())));
|
||||||
|
let spawner_called = RefCell::new(false);
|
||||||
|
|
||||||
|
let result = call_internal(
|
||||||
|
&config,
|
||||||
|
false,
|
||||||
|
&|| Ok(cache.borrow_mut().take()),
|
||||||
|
&|_| panic!("saver must not be called in foreground"),
|
||||||
|
&|_| panic!("scanner must not be called when cache is fresh"),
|
||||||
|
&|| *spawner_called.borrow_mut() = true,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap(), cached);
|
||||||
|
assert!(
|
||||||
|
!spawner_called.into_inner(),
|
||||||
|
"fresh cache should not trigger background refresh"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_stale_cache_immediately_and_spawn_background_refresh() {
|
||||||
|
let config = make_config(true, 24);
|
||||||
|
let cached = vec![PathBuf::from("/cached/project")];
|
||||||
|
let cache = RefCell::new(Some(stale_cache(cached.clone())));
|
||||||
|
let spawner_called = RefCell::new(false);
|
||||||
|
|
||||||
|
let result = call_internal(
|
||||||
|
&config,
|
||||||
|
false,
|
||||||
|
&|| Ok(cache.borrow_mut().take()),
|
||||||
|
&|_| panic!("saver must not be called in foreground"),
|
||||||
|
&|_| panic!("scanner must not be called in foreground"),
|
||||||
|
&|| *spawner_called.borrow_mut() = true,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap(), cached);
|
||||||
|
assert!(
|
||||||
|
spawner_called.into_inner(),
|
||||||
|
"stale cache must trigger background refresh"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_spawn_background_refresh_when_cache_has_no_fingerprints() {
|
||||||
|
let config = make_config(true, 24);
|
||||||
|
let cached = vec![PathBuf::from("/old/project")];
|
||||||
|
// Old cache format: no dir_mtimes
|
||||||
|
let old_cache = RefCell::new(Some(ProjectCache::new(cached.clone(), HashMap::new())));
|
||||||
|
let spawner_called = RefCell::new(false);
|
||||||
|
|
||||||
|
let result = call_internal(
|
||||||
|
&config,
|
||||||
|
false,
|
||||||
|
&|| Ok(old_cache.borrow_mut().take()),
|
||||||
|
&|_| panic!("saver must not be called in foreground"),
|
||||||
|
&|_| panic!("scanner must not be called in foreground"),
|
||||||
|
&|| *spawner_called.borrow_mut() = true,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap(), cached);
|
||||||
|
assert!(
|
||||||
|
spawner_called.into_inner(),
|
||||||
|
"old cache format must trigger background refresh"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
78
src/main.rs
78
src/main.rs
@ -4,7 +4,13 @@ use std::io::Write;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use tmuxido::config::Config;
|
use tmuxido::config::Config;
|
||||||
use tmuxido::{get_projects, launch_tmux_session, show_cache_status};
|
use tmuxido::deps::ensure_dependencies;
|
||||||
|
use tmuxido::self_update;
|
||||||
|
use tmuxido::update_check;
|
||||||
|
use tmuxido::{
|
||||||
|
get_projects, launch_tmux_session, refresh_cache, setup_desktop_integration_wizard,
|
||||||
|
setup_shortcut_wizard, show_cache_status,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(
|
#[command(
|
||||||
@ -23,17 +29,61 @@ struct Args {
|
|||||||
/// Show cache status and exit
|
/// Show cache status and exit
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
cache_status: bool,
|
cache_status: bool,
|
||||||
|
|
||||||
|
/// Update tmuxido to the latest version
|
||||||
|
#[arg(long)]
|
||||||
|
update: bool,
|
||||||
|
|
||||||
|
/// Set up a keyboard shortcut to launch tmuxido
|
||||||
|
#[arg(long)]
|
||||||
|
setup_shortcut: bool,
|
||||||
|
|
||||||
|
/// Install the .desktop entry and icon for app launcher integration
|
||||||
|
#[arg(long)]
|
||||||
|
setup_desktop_shortcut: bool,
|
||||||
|
|
||||||
|
/// Internal: rebuild cache in background (used by stale-while-revalidate)
|
||||||
|
#[arg(long, hide = true)]
|
||||||
|
background_refresh: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
|
||||||
|
// Handle self-update before anything else
|
||||||
|
if args.update {
|
||||||
|
return self_update::self_update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle background cache refresh (spawned internally by stale-while-revalidate).
|
||||||
|
// Runs early to avoid unnecessary dependency checks and config prompts.
|
||||||
|
if args.background_refresh {
|
||||||
|
let config = Config::load()?;
|
||||||
|
return refresh_cache(&config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle standalone shortcut setup
|
||||||
|
if args.setup_shortcut {
|
||||||
|
return setup_shortcut_wizard();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle standalone desktop integration setup
|
||||||
|
if args.setup_desktop_shortcut {
|
||||||
|
return setup_desktop_integration_wizard();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that fzf and tmux are installed; offer to install if missing
|
||||||
|
ensure_dependencies()?;
|
||||||
|
|
||||||
// Ensure config exists
|
// Ensure config exists
|
||||||
Config::ensure_config_exists()?;
|
Config::ensure_config_exists()?;
|
||||||
|
|
||||||
// Load config
|
// Load config
|
||||||
let config = Config::load()?;
|
let config = Config::load()?;
|
||||||
|
|
||||||
|
// Periodic update check (silent on failure or no update)
|
||||||
|
update_check::check_and_notify(&config);
|
||||||
|
|
||||||
// Handle cache status command
|
// Handle cache status command
|
||||||
if args.cache_status {
|
if args.cache_status {
|
||||||
show_cache_status(&config)?;
|
show_cache_status(&config)?;
|
||||||
@ -66,8 +116,34 @@ fn main() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn readme_preview_command() -> String {
|
||||||
|
let glow_available = Command::new("sh")
|
||||||
|
.args(["-c", "command -v glow"])
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
// CLICOLOR_FORCE=1 tells termenv (used by glow/glamour) to enable ANSI
|
||||||
|
// colors even when stdout is not a TTY (fzf preview runs in a pipe).
|
||||||
|
// Without it, glow falls back to bold-only "notty" style with no colors.
|
||||||
|
// Use `sh -c '...' -- {}` so the command runs in POSIX sh regardless of
|
||||||
|
// the user's $SHELL (fish, zsh, bash, etc.).
|
||||||
|
let viewer_cmd = if glow_available {
|
||||||
|
"CLICOLOR_FORCE=1 glow -s dark"
|
||||||
|
} else {
|
||||||
|
"cat"
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
r#"sh -c 'readme="$1/README.md"; [ -f "$readme" ] && {viewer_cmd} "$readme" || echo "No README.md"' -- {{}}"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn select_project_with_fzf(projects: &[PathBuf]) -> Result<PathBuf> {
|
fn select_project_with_fzf(projects: &[PathBuf]) -> Result<PathBuf> {
|
||||||
|
let preview_cmd = readme_preview_command();
|
||||||
let mut child = Command::new("fzf")
|
let mut child = Command::new("fzf")
|
||||||
|
.arg("--preview")
|
||||||
|
.arg(&preview_cmd)
|
||||||
|
.arg("--preview-window")
|
||||||
|
.arg("right:40%")
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.spawn()
|
.spawn()
|
||||||
|
|||||||
254
src/self_update.rs
Normal file
254
src/self_update.rs
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
const REPO: &str = "cinco/tmuxido";
|
||||||
|
const BASE_URL: &str = "https://github.com";
|
||||||
|
const API_BASE: &str = "https://api.github.com";
|
||||||
|
|
||||||
|
/// Check if running from cargo (development mode)
|
||||||
|
fn is_dev_build() -> bool {
|
||||||
|
option_env!("CARGO_PKG_NAME").is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current version from cargo
|
||||||
|
pub fn current_version() -> &'static str {
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect system architecture
|
||||||
|
fn detect_arch() -> Result<&'static str> {
|
||||||
|
let arch = std::env::consts::ARCH;
|
||||||
|
match arch {
|
||||||
|
"x86_64" => Ok("tmuxido-x86_64-linux"),
|
||||||
|
"aarch64" => Ok("tmuxido-aarch64-linux"),
|
||||||
|
_ => Err(anyhow::anyhow!("Unsupported architecture: {}", arch)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse tag_name from a GitHub releases/latest JSON response
|
||||||
|
fn parse_latest_tag(response: &str) -> Result<String> {
|
||||||
|
let tag: serde_json::Value =
|
||||||
|
serde_json::from_str(response).context("Failed to parse release API response")?;
|
||||||
|
tag.get("tag_name")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.map(|t| t.to_string())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Could not extract tag_name from release"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch latest release tag from GitHub API
|
||||||
|
pub(crate) fn fetch_latest_tag() -> Result<String> {
|
||||||
|
let url = format!("{}/repos/{}/releases/latest", API_BASE, REPO);
|
||||||
|
|
||||||
|
let output = Command::new("curl")
|
||||||
|
.args([
|
||||||
|
"-fsSL",
|
||||||
|
"-H",
|
||||||
|
"Accept: application/vnd.github.v3+json",
|
||||||
|
&url,
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.context("Failed to execute curl. Make sure curl is installed.")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Failed to fetch latest release: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_latest_tag(&String::from_utf8_lossy(&output.stdout))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get path to current executable
|
||||||
|
fn get_current_exe() -> Result<PathBuf> {
|
||||||
|
std::env::current_exe().context("Failed to get current executable path")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download binary to a temporary location
|
||||||
|
fn download_binary(tag: &str, arch: &str, temp_path: &std::path::Path) -> Result<()> {
|
||||||
|
let url = format!("{}/{}/releases/download/{}/{}", BASE_URL, REPO, tag, arch);
|
||||||
|
|
||||||
|
println!("Downloading {}...", url);
|
||||||
|
|
||||||
|
let output = Command::new("curl")
|
||||||
|
.args(["-fsSL", &url, "-o", &temp_path.to_string_lossy()])
|
||||||
|
.output()
|
||||||
|
.context("Failed to execute curl for download")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Failed to download binary: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make executable
|
||||||
|
let mut perms = std::fs::metadata(temp_path)?.permissions();
|
||||||
|
perms.set_mode(0o755);
|
||||||
|
std::fs::set_permissions(temp_path, perms)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform self-update
|
||||||
|
pub fn self_update() -> Result<()> {
|
||||||
|
if is_dev_build() {
|
||||||
|
println!("Development build detected. Skipping self-update.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = current_version();
|
||||||
|
println!("Current version: {}", current);
|
||||||
|
|
||||||
|
let latest = fetch_latest_tag()?;
|
||||||
|
let latest_clean = latest.trim_start_matches('v');
|
||||||
|
println!("Latest version: {}", latest);
|
||||||
|
|
||||||
|
// Compare versions (simple string comparison for semver without 'v' prefix)
|
||||||
|
if latest_clean == current {
|
||||||
|
println!("Already up to date!");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if latest is actually newer
|
||||||
|
match version_compare(latest_clean, current) {
|
||||||
|
std::cmp::Ordering::Less => {
|
||||||
|
println!("Current version is newer than release. Skipping update.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
std::cmp::Ordering::Equal => {
|
||||||
|
println!("Already up to date!");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let arch = detect_arch()?;
|
||||||
|
let exe_path = get_current_exe()?;
|
||||||
|
|
||||||
|
// Create temporary file in same directory as target (for atomic rename)
|
||||||
|
let exe_dir = exe_path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Could not determine executable directory"))?;
|
||||||
|
let temp_path = exe_dir.join(".tmuxido.new");
|
||||||
|
|
||||||
|
println!("Downloading update...");
|
||||||
|
download_binary(&latest, arch, &temp_path)?;
|
||||||
|
|
||||||
|
// Verify the downloaded binary works
|
||||||
|
let verify = Command::new(&temp_path).arg("--version").output();
|
||||||
|
if let Err(e) = verify {
|
||||||
|
let _ = std::fs::remove_file(&temp_path);
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Downloaded binary verification failed: {}",
|
||||||
|
e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic replace: rename old to .old, rename new to target
|
||||||
|
let backup_path = exe_path.with_extension("old");
|
||||||
|
|
||||||
|
// Remove old backup if exists
|
||||||
|
let _ = std::fs::remove_file(&backup_path);
|
||||||
|
|
||||||
|
// Rename current to backup
|
||||||
|
std::fs::rename(&exe_path, &backup_path)
|
||||||
|
.context("Failed to backup current binary (is tmuxido running?)")?;
|
||||||
|
|
||||||
|
// Move new to current location
|
||||||
|
if let Err(e) = std::fs::rename(&temp_path, &exe_path) {
|
||||||
|
// Restore backup on failure
|
||||||
|
let _ = std::fs::rename(&backup_path, &exe_path);
|
||||||
|
return Err(anyhow::anyhow!("Failed to install new binary: {}", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove backup on success
|
||||||
|
let _ = std::fs::remove_file(&backup_path);
|
||||||
|
|
||||||
|
println!("Successfully updated to {}!", latest);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare two semver versions
|
||||||
|
pub(crate) fn version_compare(a: &str, b: &str) -> std::cmp::Ordering {
|
||||||
|
let parse = |s: &str| {
|
||||||
|
s.split('.')
|
||||||
|
.filter_map(|n| n.parse::<u32>().ok())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
let a_parts = parse(a);
|
||||||
|
let b_parts = parse(b);
|
||||||
|
|
||||||
|
for (a_part, b_part) in a_parts.iter().zip(b_parts.iter()) {
|
||||||
|
match a_part.cmp(b_part) {
|
||||||
|
std::cmp::Ordering::Equal => continue,
|
||||||
|
other => return other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a_parts.len().cmp(&b_parts.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_detect_current_version() {
|
||||||
|
let version = current_version();
|
||||||
|
// Version should be non-empty and contain dots
|
||||||
|
assert!(!version.is_empty());
|
||||||
|
assert!(version.contains('.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_prefix_arch_asset_with_tmuxido() {
|
||||||
|
let arch = detect_arch().expect("should detect supported arch");
|
||||||
|
assert!(
|
||||||
|
arch.starts_with("tmuxido-"),
|
||||||
|
"asset name must start with 'tmuxido-', got: {arch}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
arch.ends_with("-linux"),
|
||||||
|
"asset name must end with '-linux', got: {arch}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_parse_tag_from_github_latest_release_response() {
|
||||||
|
let json = r#"{"tag_name":"0.7.0","name":"0.7.0","body":"release notes"}"#;
|
||||||
|
assert_eq!(parse_latest_tag(json).unwrap(), "0.7.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_error_when_tag_name_missing() {
|
||||||
|
let json = r#"{"name":"0.7.0","body":"no tag_name field"}"#;
|
||||||
|
assert!(parse_latest_tag(json).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_error_when_response_is_invalid_json() {
|
||||||
|
assert!(parse_latest_tag("not valid json").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_compare_versions_correctly() {
|
||||||
|
assert_eq!(
|
||||||
|
version_compare("0.3.0", "0.2.4"),
|
||||||
|
std::cmp::Ordering::Greater
|
||||||
|
);
|
||||||
|
assert_eq!(version_compare("0.2.4", "0.3.0"), std::cmp::Ordering::Less);
|
||||||
|
assert_eq!(version_compare("0.3.0", "0.3.0"), std::cmp::Ordering::Equal);
|
||||||
|
assert_eq!(
|
||||||
|
version_compare("1.0.0", "0.9.9"),
|
||||||
|
std::cmp::Ordering::Greater
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
version_compare("0.10.0", "0.9.0"),
|
||||||
|
std::cmp::Ordering::Greater
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
984
src/shortcut.rs
Normal 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(¤t_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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
219
src/update_check.rs
Normal file
219
src/update_check.rs
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::self_update;
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
struct UpdateCheckCache {
|
||||||
|
last_checked: u64,
|
||||||
|
latest_version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_and_notify(config: &Config) {
|
||||||
|
let cache = load_cache();
|
||||||
|
check_and_notify_internal(
|
||||||
|
config.update_check_interval_hours,
|
||||||
|
cache,
|
||||||
|
&|| self_update::fetch_latest_tag(),
|
||||||
|
&save_cache,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_and_notify_internal(
|
||||||
|
interval_hours: u64,
|
||||||
|
mut cache: UpdateCheckCache,
|
||||||
|
fetcher: &dyn Fn() -> Result<String>,
|
||||||
|
saver: &dyn Fn(&UpdateCheckCache),
|
||||||
|
) -> bool {
|
||||||
|
if interval_hours == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let elapsed = elapsed_hours(cache.last_checked);
|
||||||
|
|
||||||
|
if elapsed >= interval_hours
|
||||||
|
&& let Ok(latest) = fetcher()
|
||||||
|
{
|
||||||
|
let latest_clean = latest.trim_start_matches('v').to_string();
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
cache.last_checked = now;
|
||||||
|
cache.latest_version = latest_clean;
|
||||||
|
saver(&cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = self_update::current_version();
|
||||||
|
let latest_clean = cache.latest_version.trim_start_matches('v');
|
||||||
|
if !latest_clean.is_empty()
|
||||||
|
&& self_update::version_compare(latest_clean, current) == std::cmp::Ordering::Greater
|
||||||
|
{
|
||||||
|
print_update_notice(current, latest_clean);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_update_notice(current: &str, latest: &str) {
|
||||||
|
let msg1 = format!(" Update available: {} \u{2192} {} ", current, latest);
|
||||||
|
let msg2 = " Run tmuxido --update to install. ";
|
||||||
|
let w1 = msg1.chars().count();
|
||||||
|
let w2 = msg2.chars().count();
|
||||||
|
let width = w1.max(w2);
|
||||||
|
let border = "\u{2500}".repeat(width);
|
||||||
|
println!("\u{250c}{}\u{2510}", border);
|
||||||
|
println!("\u{2502}{}\u{2502}", pad_to_chars(&msg1, width));
|
||||||
|
println!("\u{2502}{}\u{2502}", pad_to_chars(msg2, width));
|
||||||
|
println!("\u{2514}{}\u{2518}", border);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pad_to_chars(s: &str, width: usize) -> String {
|
||||||
|
let char_count = s.chars().count();
|
||||||
|
if char_count >= width {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}{}", s, " ".repeat(width - char_count))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_path() -> Result<PathBuf> {
|
||||||
|
let cache_dir = dirs::cache_dir()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Could not determine cache directory"))?
|
||||||
|
.join("tmuxido");
|
||||||
|
Ok(cache_dir.join("update_check.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_cache() -> UpdateCheckCache {
|
||||||
|
cache_path()
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_cache(cache: &UpdateCheckCache) {
|
||||||
|
if let Ok(path) = cache_path() {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
if let Ok(json) = serde_json::to_string(cache) {
|
||||||
|
let _ = std::fs::write(path, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn elapsed_hours(ts: u64) -> u64 {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
now.saturating_sub(ts) / 3600
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
fn make_cache(last_checked: u64, latest_version: &str) -> UpdateCheckCache {
|
||||||
|
UpdateCheckCache {
|
||||||
|
last_checked,
|
||||||
|
latest_version: latest_version.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_ts() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_not_notify_when_interval_is_zero() {
|
||||||
|
let cache = make_cache(0, "99.0.0");
|
||||||
|
let fetcher_called = RefCell::new(false);
|
||||||
|
|
||||||
|
let result = check_and_notify_internal(
|
||||||
|
0,
|
||||||
|
cache,
|
||||||
|
&|| {
|
||||||
|
*fetcher_called.borrow_mut() = true;
|
||||||
|
Ok("99.0.0".to_string())
|
||||||
|
},
|
||||||
|
&|_| {},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!result);
|
||||||
|
assert!(!fetcher_called.into_inner());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_not_check_when_interval_not_elapsed() {
|
||||||
|
let cache = make_cache(now_ts(), "");
|
||||||
|
let fetcher_called = RefCell::new(false);
|
||||||
|
|
||||||
|
check_and_notify_internal(
|
||||||
|
24,
|
||||||
|
cache,
|
||||||
|
&|| {
|
||||||
|
*fetcher_called.borrow_mut() = true;
|
||||||
|
Ok("99.0.0".to_string())
|
||||||
|
},
|
||||||
|
&|_| {},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!fetcher_called.into_inner());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_check_when_interval_elapsed() {
|
||||||
|
let cache = make_cache(0, "");
|
||||||
|
let fetcher_called = RefCell::new(false);
|
||||||
|
|
||||||
|
check_and_notify_internal(
|
||||||
|
1,
|
||||||
|
cache,
|
||||||
|
&|| {
|
||||||
|
*fetcher_called.borrow_mut() = true;
|
||||||
|
Ok(self_update::current_version().to_string())
|
||||||
|
},
|
||||||
|
&|_| {},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(fetcher_called.into_inner());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_not_notify_when_versions_equal() {
|
||||||
|
let current = self_update::current_version();
|
||||||
|
let cache = make_cache(now_ts(), current);
|
||||||
|
|
||||||
|
let result = check_and_notify_internal(24, cache, &|| unreachable!(), &|_| {});
|
||||||
|
|
||||||
|
assert!(!result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_detect_update_available() {
|
||||||
|
let cache = make_cache(now_ts(), "99.0.0");
|
||||||
|
|
||||||
|
let result = check_and_notify_internal(24, cache, &|| unreachable!(), &|_| {});
|
||||||
|
|
||||||
|
assert!(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_not_detect_update_when_current_is_newer() {
|
||||||
|
let cache = make_cache(now_ts(), "0.0.1");
|
||||||
|
|
||||||
|
let result = check_and_notify_internal(24, cache, &|| unreachable!(), &|_| {});
|
||||||
|
|
||||||
|
assert!(!result);
|
||||||
|
}
|
||||||
|
}
|
||||||
137
tests/deps.rs
Normal file
137
tests/deps.rs
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
use tmuxido::deps::{
|
||||||
|
BinaryChecker, Dep, PackageManager, SystemBinaryChecker, check_missing, detect_package_manager,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- SystemBinaryChecker (real system calls) ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn system_checker_finds_sh_binary() {
|
||||||
|
let checker = SystemBinaryChecker;
|
||||||
|
assert!(
|
||||||
|
checker.is_available("sh"),
|
||||||
|
"`sh` must be present on any Unix system"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn system_checker_returns_false_for_nonexistent_binary() {
|
||||||
|
let checker = SystemBinaryChecker;
|
||||||
|
assert!(!checker.is_available("tmuxido_nonexistent_xyz_42"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- detect_package_manager on real system ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_detect_some_package_manager_on_linux() {
|
||||||
|
let checker = SystemBinaryChecker;
|
||||||
|
let pm = detect_package_manager(&checker);
|
||||||
|
assert!(
|
||||||
|
pm.is_some(),
|
||||||
|
"Expected to detect at least one package manager on this Linux system"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- PackageManager metadata completeness ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_package_managers_have_non_empty_detection_binary() {
|
||||||
|
for pm in PackageManager::all_ordered() {
|
||||||
|
assert!(
|
||||||
|
!pm.detection_binary().is_empty(),
|
||||||
|
"{:?} has empty detection binary",
|
||||||
|
pm
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_package_managers_have_non_empty_display_name() {
|
||||||
|
for pm in PackageManager::all_ordered() {
|
||||||
|
assert!(
|
||||||
|
!pm.display_name().is_empty(),
|
||||||
|
"{:?} has empty display name",
|
||||||
|
pm
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_command_always_starts_with_sudo() {
|
||||||
|
let packages = &["fzf", "tmux"];
|
||||||
|
for pm in PackageManager::all_ordered() {
|
||||||
|
let cmd = pm.install_command(packages);
|
||||||
|
assert_eq!(
|
||||||
|
cmd.first().map(String::as_str),
|
||||||
|
Some("sudo"),
|
||||||
|
"{} install command should start with sudo",
|
||||||
|
pm.display_name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_command_always_contains_requested_packages() {
|
||||||
|
let packages = &["fzf", "tmux"];
|
||||||
|
for pm in PackageManager::all_ordered() {
|
||||||
|
let cmd = pm.install_command(packages);
|
||||||
|
assert!(
|
||||||
|
cmd.contains(&"fzf".to_string()),
|
||||||
|
"{} command missing 'fzf'",
|
||||||
|
pm.display_name()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
cmd.contains(&"tmux".to_string()),
|
||||||
|
"{} command missing 'tmux'",
|
||||||
|
pm.display_name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dep completeness ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dep_package_names_are_standard() {
|
||||||
|
assert_eq!(Dep::Fzf.package_name(), "fzf");
|
||||||
|
assert_eq!(Dep::Tmux.package_name(), "tmux");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_deps_have_matching_binary_and_package_names() {
|
||||||
|
for dep in Dep::all() {
|
||||||
|
assert!(!dep.binary_name().is_empty());
|
||||||
|
assert!(!dep.package_name().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- check_missing on real system ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_missing_returns_only_actually_missing_tools() {
|
||||||
|
let checker = SystemBinaryChecker;
|
||||||
|
let missing = check_missing(&checker);
|
||||||
|
// Every item reported as missing must NOT be findable via `which`
|
||||||
|
for dep in &missing {
|
||||||
|
assert!(
|
||||||
|
!checker.is_available(dep.binary_name()),
|
||||||
|
"{} reported as missing but `which` finds it",
|
||||||
|
dep.binary_name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_missing_does_not_report_present_tools_as_missing() {
|
||||||
|
let checker = SystemBinaryChecker;
|
||||||
|
let missing = check_missing(&checker);
|
||||||
|
// Every dep NOT in missing list must be available
|
||||||
|
let missing_names: Vec<&str> = missing.iter().map(|d| d.binary_name()).collect();
|
||||||
|
for dep in Dep::all() {
|
||||||
|
if !missing_names.contains(&dep.binary_name()) {
|
||||||
|
assert!(
|
||||||
|
checker.is_available(dep.binary_name()),
|
||||||
|
"{} not in missing list but `which` can't find it",
|
||||||
|
dep.binary_name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
tests/docker/Dockerfile
Normal file
43
tests/docker/Dockerfile
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# ---- Stage 1: Build (Rust stable on Debian slim) ----
|
||||||
|
FROM rust:1-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Copy manifests first so cargo can resolve deps (layer cache friendly)
|
||||||
|
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
|
||||||
|
|
||||||
|
# Copy source and build release binary
|
||||||
|
COPY src/ ./src/
|
||||||
|
RUN cargo build --release --locked
|
||||||
|
|
||||||
|
# ---- Stage 2: Test environment (fresh Ubuntu, no fzf/tmux) ----
|
||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Install only what's needed to run the test suite itself
|
||||||
|
# (git + sudo so Test 7 can install fzf/tmux via apt)
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
sudo \
|
||||||
|
git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create an unprivileged user with passwordless sudo
|
||||||
|
# (simulates a regular developer who can install packages)
|
||||||
|
RUN useradd -m -s /bin/bash testuser \
|
||||||
|
&& echo "testuser ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
|
||||||
|
|
||||||
|
# Install the tmuxido binary built in stage 1
|
||||||
|
COPY --from=builder /src/target/release/tmuxido /usr/local/bin/tmuxido
|
||||||
|
|
||||||
|
# Copy and register the test entrypoint
|
||||||
|
COPY tests/docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
|
USER testuser
|
||||||
|
WORKDIR /home/testuser
|
||||||
|
|
||||||
|
ENTRYPOINT ["entrypoint.sh"]
|
||||||
185
tests/docker/entrypoint.sh
Executable file
185
tests/docker/entrypoint.sh
Executable file
@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Test suite executed inside the Ubuntu container.
|
||||||
|
# Simulates a brand-new user running tmuxido for the first time.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
pass() { echo " ✓ $1"; PASS=$((PASS + 1)); }
|
||||||
|
fail() { echo " ✗ $1"; FAIL=$((FAIL + 1)); }
|
||||||
|
|
||||||
|
section() {
|
||||||
|
echo ""
|
||||||
|
echo "┌─ $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 1 — fzf and tmux are NOT installed yet
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ tmuxido — Container Integration Tests (Ubuntu 24.04) ║"
|
||||||
|
echo "╚══════════════════════════════════════════════════════════╝"
|
||||||
|
|
||||||
|
section "Phase 1: binary basics"
|
||||||
|
|
||||||
|
# T1 — binary is in PATH and executable
|
||||||
|
if command -v tmuxido &>/dev/null; then
|
||||||
|
pass "tmuxido found in PATH ($(command -v tmuxido))"
|
||||||
|
else
|
||||||
|
fail "tmuxido not found in PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# T2 — --help exits 0
|
||||||
|
if tmuxido --help >/dev/null 2>&1; then
|
||||||
|
pass "--help exits with code 0"
|
||||||
|
else
|
||||||
|
fail "--help returned non-zero"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# T3 — --version shows the package name
|
||||||
|
VERSION_OUT=$(tmuxido --version 2>&1 || true)
|
||||||
|
if echo "$VERSION_OUT" | grep -q "tmuxido"; then
|
||||||
|
pass "--version output contains 'tmuxido' → $VERSION_OUT"
|
||||||
|
else
|
||||||
|
fail "--version output unexpected: $VERSION_OUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 2 — dependency detection (fzf and tmux absent)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
section "Phase 2: dependency detection (fzf and tmux not installed)"
|
||||||
|
|
||||||
|
# Pipe "n" so tmuxido declines to install and exits
|
||||||
|
DEP_OUT=$(echo "n" | tmuxido 2>&1 || true)
|
||||||
|
|
||||||
|
# T4 — fzf reported as missing
|
||||||
|
if echo "$DEP_OUT" | grep -q "fzf"; then
|
||||||
|
pass "fzf detected as missing"
|
||||||
|
else
|
||||||
|
fail "fzf NOT detected as missing. Full output:\n$DEP_OUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# T5 — tmux reported as missing
|
||||||
|
if echo "$DEP_OUT" | grep -q "tmux"; then
|
||||||
|
pass "tmux detected as missing"
|
||||||
|
else
|
||||||
|
fail "tmux NOT detected as missing. Full output:\n$DEP_OUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# T6 — "not installed" heading appears
|
||||||
|
if echo "$DEP_OUT" | grep -q "not installed"; then
|
||||||
|
pass "User-facing 'not installed' message shown"
|
||||||
|
else
|
||||||
|
fail "'not installed' message missing. Full output:\n$DEP_OUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# T7 — apt detected as package manager (Ubuntu 24.04)
|
||||||
|
if echo "$DEP_OUT" | grep -q "apt"; then
|
||||||
|
pass "apt detected as the package manager"
|
||||||
|
else
|
||||||
|
fail "apt NOT detected. Full output:\n$DEP_OUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# T8 — install command includes sudo apt install
|
||||||
|
if echo "$DEP_OUT" | grep -q "sudo apt install"; then
|
||||||
|
pass "Install command 'sudo apt install' shown to user"
|
||||||
|
else
|
||||||
|
fail "Install command incorrect. Full output:\n$DEP_OUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# T9 — cancellation message when user answers "n"
|
||||||
|
if echo "$DEP_OUT" | grep -q "cancelled\|Cancelled\|manually"; then
|
||||||
|
pass "Graceful cancellation message shown"
|
||||||
|
else
|
||||||
|
fail "Cancellation message missing. Full output:\n$DEP_OUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 3 — install deps and run full workflow
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
section "Phase 3: full workflow (after installing fzf, tmux and git)"
|
||||||
|
|
||||||
|
echo " Installing fzf, tmux via apt (this may take a moment)..."
|
||||||
|
sudo apt-get update -qq 2>/dev/null
|
||||||
|
sudo apt-get install -y --no-install-recommends fzf tmux 2>/dev/null
|
||||||
|
|
||||||
|
# T10 — fzf now available
|
||||||
|
if command -v fzf &>/dev/null; then
|
||||||
|
pass "fzf installed successfully ($(fzf --version 2>&1 | head -1))"
|
||||||
|
else
|
||||||
|
fail "fzf still not available after installation"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# T11 — tmux now available
|
||||||
|
if command -v tmux &>/dev/null; then
|
||||||
|
pass "tmux installed successfully ($(tmux -V))"
|
||||||
|
else
|
||||||
|
fail "tmux still not available after installation"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# T12 — tmuxido no longer triggers dependency prompt
|
||||||
|
NO_DEP_OUT=$(echo "" | tmuxido 2>&1 || true)
|
||||||
|
if echo "$NO_DEP_OUT" | grep -q "not installed"; then
|
||||||
|
fail "Dependency prompt still shown after installing deps"
|
||||||
|
else
|
||||||
|
pass "No dependency prompt after deps are installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# T13 — set up a minimal git project tree for scanning
|
||||||
|
mkdir -p ~/Projects/demo-app
|
||||||
|
git -C ~/Projects/demo-app init --quiet
|
||||||
|
git -C ~/Projects/demo-app config user.email "test@test.com"
|
||||||
|
git -C ~/Projects/demo-app config user.name "Test"
|
||||||
|
|
||||||
|
mkdir -p ~/.config/tmuxido
|
||||||
|
cat > ~/.config/tmuxido/tmuxido.toml <<'EOF'
|
||||||
|
paths = ["~/Projects"]
|
||||||
|
max_depth = 3
|
||||||
|
cache_enabled = true
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# T13 — --refresh scans and finds our demo project
|
||||||
|
REFRESH_OUT=$(tmuxido --refresh 2>&1 || true)
|
||||||
|
if echo "$REFRESH_OUT" | grep -q "projects\|Projects"; then
|
||||||
|
pass "--refresh scanned and reported projects"
|
||||||
|
else
|
||||||
|
fail "--refresh output unexpected: $REFRESH_OUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# T14 — --cache-status reports the cache that was just built
|
||||||
|
CACHE_OUT=$(tmuxido --cache-status 2>&1 || true)
|
||||||
|
if echo "$CACHE_OUT" | grep -qi "cache"; then
|
||||||
|
pass "--cache-status reports cache info"
|
||||||
|
else
|
||||||
|
fail "--cache-status output unexpected: $CACHE_OUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# T15 — cache contains our demo project
|
||||||
|
if echo "$CACHE_OUT" | grep -q "Projects cached: [^0]"; then
|
||||||
|
pass "Cache contains at least 1 project"
|
||||||
|
else
|
||||||
|
# Try alternate grep in case format differs
|
||||||
|
if echo "$CACHE_OUT" | grep -q "cached:"; then
|
||||||
|
pass "--cache-status shows cached projects (count check skipped)"
|
||||||
|
else
|
||||||
|
fail "Cache appears empty. Output: $CACHE_OUT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Summary
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════════════════════╗"
|
||||||
|
printf "║ Results: %-3d passed, %-3d failed%*s║\n" \
|
||||||
|
"$PASS" "$FAIL" $((24 - ${#PASS} - ${#FAIL})) ""
|
||||||
|
echo "╚══════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
[ "$FAIL" -eq 0 ]
|
||||||
54
tests/docker/run.sh
Executable file
54
tests/docker/run.sh
Executable file
@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the tmuxido Docker test image and run the container integration tests.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./tests/docker/run.sh # build + run
|
||||||
|
# ./tests/docker/run.sh --no-cache # force rebuild from scratch
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
IMAGE_NAME="tmuxido-test"
|
||||||
|
|
||||||
|
# Propagate --no-cache if requested
|
||||||
|
BUILD_FLAGS=()
|
||||||
|
if [[ "${1:-}" == "--no-cache" ]]; then
|
||||||
|
BUILD_FLAGS+=(--no-cache)
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "╔══════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ tmuxido — Docker Integration Test Runner ║"
|
||||||
|
echo "╚══════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
echo "Project root : $PROJECT_ROOT"
|
||||||
|
echo "Dockerfile : $SCRIPT_DIR/Dockerfile"
|
||||||
|
echo "Image name : $IMAGE_NAME"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ---- Build ----------------------------------------------------------------
|
||||||
|
echo "Building image (stage 1: rust compile, stage 2: ubuntu test env)..."
|
||||||
|
docker build \
|
||||||
|
"${BUILD_FLAGS[@]}" \
|
||||||
|
--tag "$IMAGE_NAME" \
|
||||||
|
--file "$SCRIPT_DIR/Dockerfile" \
|
||||||
|
"$PROJECT_ROOT"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Build complete. Running tests..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ---- Run ------------------------------------------------------------------
|
||||||
|
docker run \
|
||||||
|
--rm \
|
||||||
|
--name "${IMAGE_NAME}-run" \
|
||||||
|
"$IMAGE_NAME"
|
||||||
|
|
||||||
|
EXIT=$?
|
||||||
|
|
||||||
|
if [ "$EXIT" -eq 0 ]; then
|
||||||
|
echo "All tests passed."
|
||||||
|
else
|
||||||
|
echo "Some tests FAILED (exit $EXIT)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$EXIT"
|
||||||
@ -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
165
tests/shortcut.rs
Normal 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
10
tmuxido.desktop
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user