Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3380c668a | |||
| 8aa341080d | |||
| 2abf7e77b4 | |||
| 2d7d49d548 | |||
| ee2059986c | |||
| 2da5715a34 | |||
| 4263f0379d | |||
| a8f88e852c | |||
| 0e2745acf1 | |||
| d7246298b1 | |||
| 3aacf30697 | |||
| 906eec994f | |||
| d0f6592729 | |||
| 3f72128a25 | |||
| cc16cde540 | |||
| db08840b64 | |||
| 4ecdf96db8 |
65
CHANGELOG.md
65
CHANGELOG.md
@ -4,6 +4,71 @@ 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
|
||||
|
||||
235
Cargo.lock
generated
235
Cargo.lock
generated
@ -34,29 +34,29 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.4"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.10"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.100"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "approx"
|
||||
@ -75,9 +75,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.10.0"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "by_address"
|
||||
@ -93,9 +93,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.50"
|
||||
version = "4.5.60"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c2cfd7bf8a6017ddaa4e32ffe7403d547790db06bd171c1c53926faab501623"
|
||||
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
@ -103,9 +103,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.50"
|
||||
version = "4.5.60"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a4c05b9e80c5ccd3a7ef080ad7b6ba7d6fc00a985b8b157197075677c82c7a0"
|
||||
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
@ -115,9 +115,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.49"
|
||||
version = "4.5.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
|
||||
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
@ -127,9 +127,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "0.7.6"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
||||
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
@ -282,9 +282,9 @@ checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.16"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
@ -293,9 +293,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.1"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
|
||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
@ -315,9 +315,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.0"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@ -333,12 +333,12 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.12.0"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f"
|
||||
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.0",
|
||||
"hashbrown 0.16.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
@ -351,9 +351,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.15"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
@ -369,19 +369,18 @@ checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.10"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
|
||||
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "lipgloss"
|
||||
@ -418,9 +417,9 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.6"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
@ -562,27 +561,27 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.103"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.41"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
@ -614,7 +613,7 @@ version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
@ -625,9 +624,9 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
||||
dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror 2.0.17",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -641,9 +640,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.3"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
@ -652,12 +651,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@ -711,15 +704,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.145"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"ryu",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -733,9 +726,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shellexpand"
|
||||
version = "3.1.1"
|
||||
version = "3.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb"
|
||||
checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
]
|
||||
@ -800,9 +793,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.108"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -811,12 +804,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.25.0"
|
||||
version = "3.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
|
||||
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.1",
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
@ -833,11 +826,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.17"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.17",
|
||||
"thiserror-impl 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -853,9 +846,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.17"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
|
||||
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -864,7 +857,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tmuxido"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@ -921,9 +914,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.20"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
@ -1069,16 +1062,7 @@ version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.5",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1096,30 +1080,13 @@ version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc 0.53.1",
|
||||
"windows_x86_64_gnu 0.53.1",
|
||||
"windows_x86_64_gnullvm 0.53.1",
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1128,95 +1095,47 @@ version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.13"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@ -1308,3 +1227,9 @@ dependencies = [
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tmuxido"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
edition = "2024"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
79
README.md
79
README.md
@ -17,7 +17,7 @@ A Rust-based tool to quickly find and open projects in tmux using fzf. No extern
|
||||
## Features
|
||||
|
||||
- Search for git repositories in configurable paths
|
||||
- Interactive selection using fzf
|
||||
- Interactive selection using fzf with live `README.md` preview (rendered via `glow` when available)
|
||||
- Native tmux session creation (no tmuxinator required!)
|
||||
- Support for project-specific `.tmuxido.toml` configs
|
||||
- Smart session switching (reuses existing sessions)
|
||||
@ -25,12 +25,13 @@ A Rust-based tool to quickly find and open projects in tmux using fzf. No extern
|
||||
- Smart caching system for fast subsequent runs
|
||||
- Configurable cache TTL
|
||||
- Self-update capability (`tmuxido --update`)
|
||||
- Keyboard shortcut setup for Hyprland, GNOME, and KDE (`tmuxido --setup-shortcut`)
|
||||
- Zero external dependencies (except tmux and fzf)
|
||||
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
curl -fsSL https://git.cincoeuzebio.com/cinco/Tmuxido/raw/branch/main/install.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/cinco/tmuxido/main/install.sh | sh
|
||||
```
|
||||
|
||||
Installs the latest release binary to `~/.local/bin/tmuxido`. On first run, the config file is created automatically at `~/.config/tmuxido/tmuxido.toml`.
|
||||
@ -106,6 +107,18 @@ Update tmuxido to the latest version:
|
||||
tmuxido --update
|
||||
```
|
||||
|
||||
Set up a desktop keyboard shortcut (Hyprland, GNOME, KDE):
|
||||
```bash
|
||||
tmuxido --setup-shortcut
|
||||
```
|
||||
|
||||
Install the `.desktop` entry and icon (so tmuxido appears in app launchers like Walker/Rofi):
|
||||
```bash
|
||||
tmuxido --setup-desktop-shortcut
|
||||
```
|
||||
|
||||
Both are also offered automatically on first run. Re-run them any time to reconfigure.
|
||||
|
||||
View help:
|
||||
```bash
|
||||
tmuxido --help
|
||||
@ -123,7 +136,7 @@ tmuxido --help
|
||||
3. Presents them using fzf for selection
|
||||
4. Creates or switches to a tmux session for the selected project
|
||||
5. If a `.tmuxido.toml` config exists in the project, uses it to set up custom windows and panes
|
||||
6. Otherwise, creates a default session with two windows: "editor" and "terminal"
|
||||
6. Otherwise, uses the default session config from `~/.config/tmuxido/tmuxido.toml` (configured interactively on first run)
|
||||
|
||||
## Caching
|
||||
|
||||
@ -158,11 +171,61 @@ panes = []
|
||||
|
||||
### Available Layouts
|
||||
|
||||
- `main-horizontal` - Main pane on top, others below
|
||||
- `main-vertical` - Main pane on left, others on right
|
||||
- `tiled` - All panes tiled
|
||||
- `even-horizontal` - All panes in horizontal row
|
||||
- `even-vertical` - All panes in vertical column
|
||||
**`main-horizontal`** — Main pane on top, others below
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ │
|
||||
│ main pane │
|
||||
│ │
|
||||
├──────────┬───────────┤
|
||||
│ pane 2 │ pane 3 │
|
||||
└──────────┴───────────┘
|
||||
```
|
||||
|
||||
**`main-vertical`** — Main pane on left, others on right
|
||||
|
||||
```
|
||||
┌─────────────┬────────┐
|
||||
│ │ pane 2 │
|
||||
│ main pane ├────────┤
|
||||
│ │ pane 3 │
|
||||
│ ├────────┤
|
||||
│ │ pane 4 │
|
||||
└─────────────┴────────┘
|
||||
```
|
||||
|
||||
**`tiled`** — All panes tiled equally
|
||||
|
||||
```
|
||||
┌───────────┬──────────┐
|
||||
│ pane 1 │ pane 2 │
|
||||
├───────────┼──────────┤
|
||||
│ pane 3 │ pane 4 │
|
||||
└───────────┴──────────┘
|
||||
```
|
||||
|
||||
**`even-horizontal`** — All panes side by side
|
||||
|
||||
```
|
||||
┌────────┬────────┬────────┐
|
||||
│ │ │ │
|
||||
│ pane 1 │ pane 2 │ pane 3 │
|
||||
│ │ │ │
|
||||
└────────┴────────┴────────┘
|
||||
```
|
||||
|
||||
**`even-vertical`** — All panes stacked
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ pane 1 │
|
||||
├──────────────────────┤
|
||||
│ pane 2 │
|
||||
├──────────────────────┤
|
||||
│ pane 3 │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
### Panes
|
||||
|
||||
|
||||
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 |
41
install.sh
41
install.sh
@ -3,8 +3,11 @@ set -e
|
||||
|
||||
REPO="cinco/tmuxido"
|
||||
BASE_URL="https://github.com"
|
||||
RAW_URL="https://raw.githubusercontent.com/$REPO/refs/heads/main"
|
||||
API_URL="https://api.github.com"
|
||||
INSTALL_DIR="$HOME/.local/bin"
|
||||
ICON_DIR="$HOME/.local/share/icons/hicolor/96x96/apps"
|
||||
DESKTOP_DIR="$HOME/.local/share/applications"
|
||||
|
||||
arch=$(uname -m)
|
||||
case "$arch" in
|
||||
@ -13,20 +16,48 @@ case "$arch" in
|
||||
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
tag=$(curl -fsSL \
|
||||
api_resp=$(curl -sSL \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"$API_URL/repos/$REPO/releases/latest" \
|
||||
| grep -o '"tag_name":"[^"]*"' | cut -d'"' -f4)
|
||||
"$API_URL/repos/$REPO/releases/latest")
|
||||
tag=$(printf '%s' "$api_resp" | grep -o '"tag_name": *"[^"]*"' | grep -o '"[^"]*"$' | tr -d '"')
|
||||
|
||||
[ -z "$tag" ] && { echo "Could not fetch latest release" >&2; exit 1; }
|
||||
if [ -z "$tag" ]; then
|
||||
echo "Could not fetch latest release." >&2
|
||||
printf 'GitHub API response: %s\n' "$api_resp" | head -c 400 >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Installing tmuxido $tag..."
|
||||
|
||||
# Binary
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
curl -fsSL "$BASE_URL/$REPO/releases/download/$tag/$file" -o "$INSTALL_DIR/tmuxido"
|
||||
chmod +x "$INSTALL_DIR/tmuxido"
|
||||
echo "Installed: $INSTALL_DIR/tmuxido"
|
||||
echo " binary → $INSTALL_DIR/tmuxido"
|
||||
|
||||
# Icon (96×96)
|
||||
mkdir -p "$ICON_DIR"
|
||||
curl -fsSL "$RAW_URL/docs/assets/tmuxido-icon_96.png" -o "$ICON_DIR/tmuxido.png"
|
||||
echo " icon → $ICON_DIR/tmuxido.png"
|
||||
|
||||
# .desktop entry
|
||||
mkdir -p "$DESKTOP_DIR"
|
||||
curl -fsSL "$RAW_URL/tmuxido.desktop" -o "$DESKTOP_DIR/tmuxido.desktop"
|
||||
echo " desktop → $DESKTOP_DIR/tmuxido.desktop"
|
||||
|
||||
# Refresh desktop database if available
|
||||
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||
update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Refresh icon cache if available
|
||||
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
||||
gtk-update-icon-cache -f -t "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
case ":$PATH:" in
|
||||
*":$INSTALL_DIR:"*) ;;
|
||||
*) echo "Note: add $INSTALL_DIR to your PATH (e.g. export PATH=\"\$HOME/.local/bin:\$PATH\")" ;;
|
||||
esac
|
||||
|
||||
echo "Done! Run 'tmuxido' to get started."
|
||||
|
||||
136
src/config.rs
136
src/config.rs
@ -96,40 +96,68 @@ impl Config {
|
||||
)
|
||||
})?;
|
||||
|
||||
// Run interactive configuration wizard
|
||||
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()?;
|
||||
// Ask whether to run the interactive wizard or apply sensible defaults
|
||||
let raw = ui::render_setup_choice_prompt()?;
|
||||
match ui::parse_setup_choice_input(&raw) {
|
||||
ui::SetupChoice::Default => {
|
||||
Self::write_default_config(&config_path)?;
|
||||
ui::render_default_config_saved(&config_path.display().to_string());
|
||||
}
|
||||
ui::SetupChoice::Wizard => {
|
||||
Self::run_wizard(&config_path)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Render styled success message before moving windows
|
||||
ui::render_config_created(&paths, max_depth, cache_enabled, cache_ttl_hours, &windows);
|
||||
|
||||
let config = Config {
|
||||
paths: paths.clone(),
|
||||
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())
|
||||
})?;
|
||||
// Offer shortcut and desktop integration regardless of setup mode
|
||||
if let Err(e) = crate::shortcut::setup_shortcut_wizard() {
|
||||
eprintln!("Warning: shortcut setup failed: {}", e);
|
||||
}
|
||||
if let Err(e) = crate::shortcut::setup_desktop_integration_wizard() {
|
||||
eprintln!("Warning: desktop integration failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(config_path)
|
||||
}
|
||||
|
||||
/// Write the built-in default config to `config_path` without any prompts.
|
||||
fn write_default_config(config_path: &std::path::Path) -> Result<()> {
|
||||
let config = Self::default_config();
|
||||
let toml_string = toml::to_string_pretty(&config).context("Failed to serialize config")?;
|
||||
fs::write(config_path, toml_string)
|
||||
.with_context(|| format!("Failed to write config file: {}", config_path.display()))
|
||||
}
|
||||
|
||||
/// Run the full interactive configuration wizard and offer shortcut / desktop setup at the end.
|
||||
fn run_wizard(config_path: &std::path::Path) -> Result<()> {
|
||||
let paths = Self::prompt_for_paths()?;
|
||||
let max_depth = Self::prompt_for_max_depth()?;
|
||||
let cache_enabled = Self::prompt_for_cache_enabled()?;
|
||||
let cache_ttl_hours = if cache_enabled {
|
||||
Self::prompt_for_cache_ttl()?
|
||||
} else {
|
||||
24
|
||||
};
|
||||
let windows = Self::prompt_for_windows()?;
|
||||
|
||||
// Render styled success message before moving windows
|
||||
ui::render_config_created(&paths, max_depth, cache_enabled, cache_ttl_hours, &windows);
|
||||
|
||||
let config = Config {
|
||||
paths,
|
||||
max_depth,
|
||||
cache_enabled,
|
||||
cache_ttl_hours,
|
||||
update_check_interval_hours: default_update_check_interval_hours(),
|
||||
default_session: SessionConfig { windows },
|
||||
};
|
||||
|
||||
let toml_string = toml::to_string_pretty(&config).context("Failed to serialize config")?;
|
||||
|
||||
fs::write(config_path, toml_string)
|
||||
.with_context(|| format!("Failed to write config file: {}", config_path.display()))
|
||||
}
|
||||
|
||||
fn prompt_for_paths() -> Result<Vec<String>> {
|
||||
// Render styled welcome banner
|
||||
ui::render_welcome_banner();
|
||||
@ -181,14 +209,19 @@ impl Config {
|
||||
window_names
|
||||
};
|
||||
|
||||
// Configure panes for each window
|
||||
// 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: None,
|
||||
layout,
|
||||
});
|
||||
}
|
||||
|
||||
@ -348,6 +381,49 @@ mod tests {
|
||||
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#"
|
||||
|
||||
327
src/lib.rs
327
src/lib.rs
@ -3,6 +3,7 @@ pub mod config;
|
||||
pub mod deps;
|
||||
pub mod self_update;
|
||||
pub mod session;
|
||||
pub mod shortcut;
|
||||
pub mod ui;
|
||||
pub mod update_check;
|
||||
|
||||
@ -15,6 +16,14 @@ use std::path::{Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
pub fn setup_shortcut_wizard() -> Result<()> {
|
||||
shortcut::setup_shortcut_wizard()
|
||||
}
|
||||
|
||||
pub fn setup_desktop_integration_wizard() -> Result<()> {
|
||||
shortcut::setup_desktop_integration_wizard()
|
||||
}
|
||||
|
||||
pub fn show_cache_status(config: &Config) -> Result<()> {
|
||||
if !config.cache_enabled {
|
||||
println!("Cache is disabled in configuration");
|
||||
@ -46,9 +55,47 @@ pub fn get_projects(config: &Config, force_refresh: bool) -> Result<Vec<PathBuf>
|
||||
&ProjectCache::load,
|
||||
&|cache| cache.save(),
|
||||
&scan_all_roots,
|
||||
&spawn_background_refresh,
|
||||
)
|
||||
}
|
||||
|
||||
/// Rebuilds the project cache incrementally. Intended to be called from a
|
||||
/// background process spawned by `get_projects` via stale-while-revalidate.
|
||||
pub fn refresh_cache(config: &Config) -> Result<()> {
|
||||
match ProjectCache::load()? {
|
||||
None => {
|
||||
let (projects, fingerprints) = scan_all_roots(config)?;
|
||||
ProjectCache::new(projects, fingerprints).save()?;
|
||||
}
|
||||
Some(mut cache) => {
|
||||
if cache.dir_mtimes.is_empty() {
|
||||
// Old cache format — full rescan
|
||||
let (projects, fingerprints) = scan_all_roots(config)?;
|
||||
ProjectCache::new(projects, fingerprints).save()?;
|
||||
} else {
|
||||
// Incremental rescan based on directory mtimes
|
||||
let changed = cache.validate_and_update(&|root| scan_from_root(root, config))?;
|
||||
if changed {
|
||||
cache.save()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_background_refresh() {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
std::process::Command::new(exe)
|
||||
.arg("--background-refresh")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn get_projects_internal(
|
||||
config: &Config,
|
||||
@ -56,45 +103,31 @@ fn get_projects_internal(
|
||||
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)?;
|
||||
eprintln!("Cache updated with {} projects", projects.len());
|
||||
return Ok(projects);
|
||||
}
|
||||
|
||||
if let Some(mut cache) = cache_loader()? {
|
||||
// Cache no formato antigo (sem dir_mtimes) → atualizar com rescan completo
|
||||
if cache.dir_mtimes.is_empty() {
|
||||
eprintln!("Upgrading cache, scanning for projects...");
|
||||
let (projects, fingerprints) = scanner(config)?;
|
||||
let new_cache = ProjectCache::new(projects.clone(), fingerprints);
|
||||
cache_saver(&new_cache)?;
|
||||
eprintln!("Cache updated with {} projects", projects.len());
|
||||
return Ok(projects);
|
||||
}
|
||||
|
||||
let changed = cache.validate_and_update(&|root| scan_from_root(root, config))?;
|
||||
if changed {
|
||||
cache_saver(&cache)?;
|
||||
eprintln!(
|
||||
"Cache updated incrementally ({} projects)",
|
||||
cache.projects.len()
|
||||
);
|
||||
} else {
|
||||
eprintln!("Using cached projects ({} projects)", cache.projects.len());
|
||||
if let Some(cache) = cache_loader()? {
|
||||
// Cache exists — return immediately (stale-while-revalidate).
|
||||
// Spawn a background refresh if the cache is stale or in old format.
|
||||
let is_stale =
|
||||
cache.dir_mtimes.is_empty() || cache.age_in_seconds() > config.cache_ttl_hours * 3600;
|
||||
if is_stale {
|
||||
refresh_spawner();
|
||||
}
|
||||
return Ok(cache.projects);
|
||||
}
|
||||
|
||||
// Sem cache ainda — scan completo inicial
|
||||
// No cache yet — first run, blocking scan is unavoidable.
|
||||
eprintln!("No cache found, scanning for projects...");
|
||||
let (projects, fingerprints) = scanner(config)?;
|
||||
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
||||
cache_saver(&cache)?;
|
||||
eprintln!("Cache updated with {} projects", projects.len());
|
||||
Ok(projects)
|
||||
}
|
||||
|
||||
@ -187,242 +220,208 @@ mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
fn create_test_config(cache_enabled: bool) -> Config {
|
||||
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: 24,
|
||||
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 = create_test_config(false);
|
||||
let projects = vec![PathBuf::from("/tmp/test/project1")];
|
||||
let fingerprints = HashMap::new();
|
||||
let expected_projects = projects.clone();
|
||||
|
||||
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 = get_projects_internal(
|
||||
let result = call_internal(
|
||||
&config,
|
||||
false,
|
||||
&|| panic!("should not load cache when disabled"),
|
||||
&|| panic!("loader must not be called when cache disabled"),
|
||||
&|_| {
|
||||
*saver_called.borrow_mut() = true;
|
||||
Ok(())
|
||||
},
|
||||
&|_| {
|
||||
*scanner_called.borrow_mut() = true;
|
||||
Ok((expected_projects.clone(), fingerprints.clone()))
|
||||
Ok((expected.clone(), HashMap::new()))
|
||||
},
|
||||
&|| *spawner_called.borrow_mut() = true,
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(scanner_called.into_inner());
|
||||
assert!(saver_called.into_inner());
|
||||
assert_eq!(result.unwrap(), projects);
|
||||
assert!(!spawner_called.into_inner());
|
||||
assert_eq!(result.unwrap(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_scan_when_force_refresh() {
|
||||
let config = create_test_config(true);
|
||||
let projects = vec![PathBuf::from("/tmp/test/project1")];
|
||||
let fingerprints = HashMap::new();
|
||||
let expected_projects = projects.clone();
|
||||
|
||||
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 = get_projects_internal(
|
||||
let result = call_internal(
|
||||
&config,
|
||||
true,
|
||||
&|| panic!("should not load cache when force refresh"),
|
||||
&|| panic!("loader must not be called on force refresh"),
|
||||
&|_| {
|
||||
*saver_called.borrow_mut() = true;
|
||||
Ok(())
|
||||
},
|
||||
&|_| {
|
||||
*scanner_called.borrow_mut() = true;
|
||||
Ok((expected_projects.clone(), fingerprints.clone()))
|
||||
Ok((expected.clone(), HashMap::new()))
|
||||
},
|
||||
&|| *spawner_called.borrow_mut() = true,
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(scanner_called.into_inner());
|
||||
assert!(saver_called.into_inner());
|
||||
assert_eq!(result.unwrap(), projects);
|
||||
assert!(!spawner_called.into_inner());
|
||||
assert_eq!(result.unwrap(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_do_initial_scan_when_no_cache_exists() {
|
||||
let config = create_test_config(true);
|
||||
let projects = vec![PathBuf::from("/tmp/test/project1")];
|
||||
let fingerprints = HashMap::new();
|
||||
let expected_projects = projects.clone();
|
||||
|
||||
let loader_called = RefCell::new(false);
|
||||
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 = get_projects_internal(
|
||||
let result = call_internal(
|
||||
&config,
|
||||
false,
|
||||
&|| {
|
||||
*loader_called.borrow_mut() = true;
|
||||
Ok(None)
|
||||
},
|
||||
&|| Ok(None),
|
||||
&|_| {
|
||||
*saver_called.borrow_mut() = true;
|
||||
Ok(())
|
||||
},
|
||||
&|_| {
|
||||
*scanner_called.borrow_mut() = true;
|
||||
Ok((expected_projects.clone(), fingerprints.clone()))
|
||||
Ok((expected.clone(), HashMap::new()))
|
||||
},
|
||||
&|| *spawner_called.borrow_mut() = true,
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(loader_called.into_inner());
|
||||
assert!(scanner_called.into_inner());
|
||||
assert!(saver_called.into_inner());
|
||||
assert_eq!(result.unwrap(), projects);
|
||||
assert!(!spawner_called.into_inner());
|
||||
assert_eq!(result.unwrap(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_upgrade_old_cache_format() {
|
||||
let config = create_test_config(true);
|
||||
let old_projects = vec![PathBuf::from("/old/project")];
|
||||
let new_projects = vec![
|
||||
PathBuf::from("/new/project1"),
|
||||
PathBuf::from("/new/project2"),
|
||||
];
|
||||
let new_fingerprints = HashMap::from([(PathBuf::from("/new"), 12345u64)]);
|
||||
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);
|
||||
|
||||
// Use RefCell<Option<>> to allow moving into closure multiple times
|
||||
let old_cache = RefCell::new(Some(ProjectCache::new(old_projects, HashMap::new())));
|
||||
|
||||
let loader_called = RefCell::new(false);
|
||||
let scanner_called = RefCell::new(false);
|
||||
let saver_count = RefCell::new(0);
|
||||
|
||||
let result = get_projects_internal(
|
||||
let result = call_internal(
|
||||
&config,
|
||||
false,
|
||||
&|| {
|
||||
*loader_called.borrow_mut() = true;
|
||||
// Take the cache out of the RefCell
|
||||
Ok(old_cache.borrow_mut().take())
|
||||
},
|
||||
&|_| {
|
||||
*saver_count.borrow_mut() += 1;
|
||||
Ok(())
|
||||
},
|
||||
&|_| {
|
||||
*scanner_called.borrow_mut() = true;
|
||||
Ok((new_projects.clone(), new_fingerprints.clone()))
|
||||
},
|
||||
&|| 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!(loader_called.into_inner());
|
||||
assert!(scanner_called.into_inner());
|
||||
assert_eq!(*saver_count.borrow(), 1);
|
||||
assert_eq!(result.unwrap(), new_projects);
|
||||
assert_eq!(result.unwrap(), cached);
|
||||
assert!(
|
||||
!spawner_called.into_inner(),
|
||||
"fresh cache should not trigger background refresh"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_use_cached_projects_when_nothing_changed() {
|
||||
let config = create_test_config(true);
|
||||
let cached_projects = vec![
|
||||
PathBuf::from("/nonexistent/project1"),
|
||||
PathBuf::from("/nonexistent/project2"),
|
||||
];
|
||||
// Use a path that doesn't exist - validate_and_update will skip rescan
|
||||
// because it can't check mtime of non-existent directory
|
||||
let cached_fingerprints =
|
||||
HashMap::from([(PathBuf::from("/definitely_nonexistent_path_xyz"), 12345u64)]);
|
||||
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);
|
||||
|
||||
// Use RefCell<Option<>> to allow moving into closure multiple times
|
||||
let cache = RefCell::new(Some(ProjectCache::new(
|
||||
cached_projects.clone(),
|
||||
cached_fingerprints,
|
||||
)));
|
||||
|
||||
let loader_called = RefCell::new(false);
|
||||
let scanner_called = RefCell::new(false);
|
||||
let saver_count = RefCell::new(0);
|
||||
|
||||
let result = get_projects_internal(
|
||||
let result = call_internal(
|
||||
&config,
|
||||
false,
|
||||
&|| {
|
||||
*loader_called.borrow_mut() = true;
|
||||
// Take the cache out of the RefCell
|
||||
Ok(cache.borrow_mut().take())
|
||||
},
|
||||
&|_| {
|
||||
*saver_count.borrow_mut() += 1;
|
||||
Ok(())
|
||||
},
|
||||
&|_| {
|
||||
*scanner_called.borrow_mut() = true;
|
||||
panic!("should not do full scan when cache is valid")
|
||||
},
|
||||
&|| 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!(loader_called.into_inner());
|
||||
// Note: When the directory in dir_mtimes doesn't exist, validate_and_update
|
||||
// treats it as "changed" and removes projects under that path.
|
||||
// This test verifies the flow completes - the specific behavior of
|
||||
// validate_and_update is tested separately in cache.rs
|
||||
let result_projects = result.unwrap();
|
||||
// Projects were removed because the tracked directory doesn't exist
|
||||
assert!(result_projects.is_empty());
|
||||
assert_eq!(result.unwrap(), cached);
|
||||
assert!(
|
||||
spawner_called.into_inner(),
|
||||
"stale cache must trigger background refresh"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_update_incrementally_when_cache_changed() {
|
||||
let config = create_test_config(true);
|
||||
let initial_projects = vec![PathBuf::from("/nonexistent/project1")];
|
||||
// Use a path that doesn't exist - validate_and_update will treat missing
|
||||
// directory as a change (unwrap_or(true) in the mtime check)
|
||||
let mut dir_mtimes = HashMap::new();
|
||||
dir_mtimes.insert(PathBuf::from("/definitely_nonexistent_path_abc"), 0u64);
|
||||
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);
|
||||
|
||||
// Use RefCell<Option<>> to allow moving into closure multiple times
|
||||
let cache = RefCell::new(Some(ProjectCache::new(initial_projects, dir_mtimes)));
|
||||
|
||||
let loader_called = RefCell::new(false);
|
||||
let saver_called = RefCell::new(false);
|
||||
|
||||
let result = get_projects_internal(
|
||||
let result = call_internal(
|
||||
&config,
|
||||
false,
|
||||
&|| {
|
||||
*loader_called.borrow_mut() = true;
|
||||
// Take the cache out of the RefCell
|
||||
Ok(cache.borrow_mut().take())
|
||||
},
|
||||
&|_| {
|
||||
*saver_called.borrow_mut() = true;
|
||||
Ok(())
|
||||
},
|
||||
&|_| panic!("full scan should not happen with incremental update"),
|
||||
&|| 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,
|
||||
);
|
||||
|
||||
// validate_and_update is called internally. Since the directory doesn't exist,
|
||||
// it treats it as "changed" and will try to rescan using scan_from_root.
|
||||
// We verify the flow completes without panicking.
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(loader_called.into_inner());
|
||||
// Note: The saver may or may not be called depending on whether
|
||||
// validate_and_update detects changes (missing dir = change)
|
||||
assert_eq!(result.unwrap(), cached);
|
||||
assert!(
|
||||
spawner_called.into_inner(),
|
||||
"old cache format must trigger background refresh"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
60
src/main.rs
60
src/main.rs
@ -7,7 +7,10 @@ use tmuxido::config::Config;
|
||||
use tmuxido::deps::ensure_dependencies;
|
||||
use tmuxido::self_update;
|
||||
use tmuxido::update_check;
|
||||
use tmuxido::{get_projects, launch_tmux_session, show_cache_status};
|
||||
use tmuxido::{
|
||||
get_projects, launch_tmux_session, refresh_cache, setup_desktop_integration_wizard,
|
||||
setup_shortcut_wizard, show_cache_status,
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
@ -30,6 +33,18 @@ struct Args {
|
||||
/// Update tmuxido to the latest version
|
||||
#[arg(long)]
|
||||
update: bool,
|
||||
|
||||
/// Set up a keyboard shortcut to launch tmuxido
|
||||
#[arg(long)]
|
||||
setup_shortcut: bool,
|
||||
|
||||
/// Install the .desktop entry and icon for app launcher integration
|
||||
#[arg(long)]
|
||||
setup_desktop_shortcut: bool,
|
||||
|
||||
/// Internal: rebuild cache in background (used by stale-while-revalidate)
|
||||
#[arg(long, hide = true)]
|
||||
background_refresh: bool,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@ -40,6 +55,23 @@ fn main() -> Result<()> {
|
||||
return self_update::self_update();
|
||||
}
|
||||
|
||||
// Handle background cache refresh (spawned internally by stale-while-revalidate).
|
||||
// Runs early to avoid unnecessary dependency checks and config prompts.
|
||||
if args.background_refresh {
|
||||
let config = Config::load()?;
|
||||
return refresh_cache(&config);
|
||||
}
|
||||
|
||||
// Handle standalone shortcut setup
|
||||
if args.setup_shortcut {
|
||||
return setup_shortcut_wizard();
|
||||
}
|
||||
|
||||
// Handle standalone desktop integration setup
|
||||
if args.setup_desktop_shortcut {
|
||||
return setup_desktop_integration_wizard();
|
||||
}
|
||||
|
||||
// Check that fzf and tmux are installed; offer to install if missing
|
||||
ensure_dependencies()?;
|
||||
|
||||
@ -84,8 +116,34 @@ fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn readme_preview_command() -> String {
|
||||
let glow_available = Command::new("sh")
|
||||
.args(["-c", "command -v glow"])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
// CLICOLOR_FORCE=1 tells termenv (used by glow/glamour) to enable ANSI
|
||||
// colors even when stdout is not a TTY (fzf preview runs in a pipe).
|
||||
// Without it, glow falls back to bold-only "notty" style with no colors.
|
||||
// Use `sh -c '...' -- {}` so the command runs in POSIX sh regardless of
|
||||
// the user's $SHELL (fish, zsh, bash, etc.).
|
||||
let viewer_cmd = if glow_available {
|
||||
"CLICOLOR_FORCE=1 glow -s dark"
|
||||
} else {
|
||||
"cat"
|
||||
};
|
||||
format!(
|
||||
r#"sh -c 'readme="$1/README.md"; [ -f "$readme" ] && {viewer_cmd} "$readme" || echo "No README.md"' -- {{}}"#
|
||||
)
|
||||
}
|
||||
|
||||
fn select_project_with_fzf(projects: &[PathBuf]) -> Result<PathBuf> {
|
||||
let preview_cmd = readme_preview_command();
|
||||
let mut child = Command::new("fzf")
|
||||
.arg("--preview")
|
||||
.arg(&preview_cmd)
|
||||
.arg("--preview-window")
|
||||
.arg("right:40%")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
473
src/ui.rs
473
src/ui.rs
@ -133,6 +133,12 @@ pub fn render_config_created(
|
||||
println!("{}", label_style.render(" 🪟 Default Windows:"));
|
||||
for window in windows {
|
||||
println!(" {}", window_style.render(&format!("◦ {}", window.name)));
|
||||
if let Some(layout) = &window.layout {
|
||||
println!(
|
||||
"{}",
|
||||
info_style.render(&format!(" └─ layout: {}", layout))
|
||||
);
|
||||
}
|
||||
if !window.panes.is_empty() {
|
||||
for (i, pane) in window.panes.iter().enumerate() {
|
||||
let pane_display = if pane.is_empty() {
|
||||
@ -299,6 +305,67 @@ pub fn render_panes_prompt(window_name: &str) -> Result<String> {
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
/// Renders a prompt for the layout of a window with multiple panes
|
||||
pub fn render_layout_prompt(window_name: &str, pane_count: usize) -> Result<Option<String>> {
|
||||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||||
let window_style = Style::new().bold(true).foreground(color_purple());
|
||||
let label_style = Style::new().foreground(color_blue());
|
||||
|
||||
println!();
|
||||
println!(
|
||||
" Layout for window {} ({} panes):",
|
||||
window_style.render(window_name),
|
||||
label_style.render(&pane_count.to_string())
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" Choose a pane layout (leave empty for no layout):")
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" 1. main-horizontal — main pane on top, others below")
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" 2. main-vertical — main pane on left, others on right")
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" 3. tiled — all panes tiled equally")
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" 4. even-horizontal — all panes side by side")
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" 5. even-vertical — all panes stacked vertically")
|
||||
);
|
||||
print!(" {} ", prompt_style.render("❯ Layout (1-5 or name):"));
|
||||
io::stdout().flush().context("Failed to flush stdout")?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin()
|
||||
.read_line(&mut input)
|
||||
.context("Failed to read input")?;
|
||||
|
||||
Ok(parse_layout_input(input.trim()))
|
||||
}
|
||||
|
||||
/// Parse layout input: accepts number (1-5) or layout name; returns None for empty/invalid
|
||||
pub fn parse_layout_input(input: &str) -> Option<String> {
|
||||
match input.trim() {
|
||||
"" => None,
|
||||
"1" | "main-horizontal" => Some("main-horizontal".to_string()),
|
||||
"2" | "main-vertical" => Some("main-vertical".to_string()),
|
||||
"3" | "tiled" => Some("tiled".to_string()),
|
||||
"4" | "even-horizontal" => Some("even-horizontal".to_string()),
|
||||
"5" | "even-vertical" => Some("even-vertical".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a prompt for a pane command
|
||||
pub fn render_pane_command_prompt(pane_name: &str) -> Result<String> {
|
||||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||||
@ -358,6 +425,279 @@ pub fn parse_cache_ttl_input(input: &str) -> Option<u64> {
|
||||
trimmed.parse::<u64>().ok().filter(|&n| n > 0)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Shortcut wizard UI
|
||||
// ============================================================================
|
||||
|
||||
/// Render warning when the desktop environment could not be detected
|
||||
pub fn render_shortcut_unknown_de() {
|
||||
let warning_style = Style::new().italic(true).foreground(color_orange());
|
||||
println!(
|
||||
"{}",
|
||||
warning_style.render(" Desktop environment not detected. Skipping shortcut setup.")
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
warning_style.render(" Run 'tmuxido --setup-shortcut' later when your DE is active.")
|
||||
);
|
||||
}
|
||||
|
||||
/// Ask the user whether to set up a keyboard shortcut. Returns `true` if yes.
|
||||
pub fn render_shortcut_setup_prompt() -> Result<bool> {
|
||||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" Set up a keyboard shortcut to launch tmuxido from anywhere?")
|
||||
);
|
||||
print!(" {} ", prompt_style.render("❯ Set up shortcut? (Y/n):"));
|
||||
io::stdout().flush().context("Failed to flush stdout")?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin()
|
||||
.read_line(&mut input)
|
||||
.context("Failed to read input")?;
|
||||
|
||||
let trimmed = input.trim().to_lowercase();
|
||||
Ok(trimmed != "n" && trimmed != "no")
|
||||
}
|
||||
|
||||
/// Ask the user for a key combo (shows the default in brackets).
|
||||
pub fn render_key_combo_prompt(default: &str) -> Result<String> {
|
||||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(&format!(
|
||||
" Enter the key combo to launch tmuxido (default: {})",
|
||||
default
|
||||
))
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" 💡 Example: Super+Shift+T, Super+Ctrl+P")
|
||||
);
|
||||
print!(
|
||||
" {} ",
|
||||
prompt_style.render(&format!("❯ Key combo [{}]:", default))
|
||||
);
|
||||
io::stdout().flush().context("Failed to flush stdout")?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin()
|
||||
.read_line(&mut input)
|
||||
.context("Failed to read input")?;
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
/// Show a conflict warning and ask whether to use the suggested alternative.
|
||||
/// Returns `true` if the user accepts the suggestion.
|
||||
pub fn render_shortcut_conflict_prompt(
|
||||
combo: &str,
|
||||
taken_by: &str,
|
||||
suggestion: &str,
|
||||
) -> Result<bool> {
|
||||
let warning_style = Style::new().foreground(color_orange());
|
||||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
warning_style.render(&format!(
|
||||
" ⚠️ {} is already taken by: {}",
|
||||
combo, taken_by
|
||||
))
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(&format!(" Use {} instead?", suggestion))
|
||||
);
|
||||
print!(" {} ", prompt_style.render("❯ Use suggestion? (Y/n):"));
|
||||
io::stdout().flush().context("Failed to flush stdout")?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin()
|
||||
.read_line(&mut input)
|
||||
.context("Failed to read input")?;
|
||||
|
||||
let trimmed = input.trim().to_lowercase();
|
||||
Ok(trimmed != "n" && trimmed != "no")
|
||||
}
|
||||
|
||||
/// Render a success message after the shortcut has been written
|
||||
pub fn render_shortcut_success(de: &str, combo: &str, details: &str, reload_hint: &str) {
|
||||
let success_style = Style::new().bold(true).foreground(color_green());
|
||||
let label_style = Style::new().foreground(color_light_gray());
|
||||
let value_style = Style::new().bold(true).foreground(color_blue());
|
||||
let info_style = Style::new().foreground(color_dark_gray());
|
||||
|
||||
println!();
|
||||
println!("{}", success_style.render(" ⌨️ Shortcut configured!"));
|
||||
println!(
|
||||
" {} {}",
|
||||
label_style.render("Combo:"),
|
||||
value_style.render(combo)
|
||||
);
|
||||
println!(
|
||||
" {} {}",
|
||||
label_style.render("Desktop:"),
|
||||
value_style.render(de)
|
||||
);
|
||||
println!(" {}", info_style.render(details));
|
||||
println!();
|
||||
println!(" {}", info_style.render(reload_hint));
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Ask the user whether to install the .desktop entry and icon
|
||||
pub fn render_desktop_integration_prompt() -> Result<bool> {
|
||||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(
|
||||
" Install a .desktop entry so tmuxido appears in app launchers (Walker, Rofi, etc.)?"
|
||||
)
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
hint_style
|
||||
.render(" Also downloads the 96×96 icon from GitHub (requires internet access).")
|
||||
);
|
||||
print!(
|
||||
" {} ",
|
||||
prompt_style.render("❯ Install desktop entry? (Y/n):")
|
||||
);
|
||||
io::stdout().flush().context("Failed to flush stdout")?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin()
|
||||
.read_line(&mut input)
|
||||
.context("Failed to read input")?;
|
||||
|
||||
let trimmed = input.trim().to_lowercase();
|
||||
Ok(trimmed != "n" && trimmed != "no")
|
||||
}
|
||||
|
||||
/// Render a success message after desktop integration is installed
|
||||
pub fn render_desktop_integration_success(result: &crate::shortcut::DesktopInstallResult) {
|
||||
let success_style = Style::new().bold(true).foreground(color_green());
|
||||
let label_style = Style::new().foreground(color_light_gray());
|
||||
let value_style = Style::new().bold(true).foreground(color_blue());
|
||||
let warn_style = Style::new().italic(true).foreground(color_orange());
|
||||
|
||||
println!();
|
||||
println!("{}", success_style.render(" 🖥️ Desktop entry installed!"));
|
||||
println!(
|
||||
" {} {}",
|
||||
label_style.render(".desktop:"),
|
||||
value_style.render(&result.desktop_path.display().to_string())
|
||||
);
|
||||
if result.icon_downloaded {
|
||||
println!(
|
||||
" {} {}",
|
||||
label_style.render("icon:"),
|
||||
value_style.render(&result.icon_path.display().to_string())
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {}",
|
||||
warn_style.render("icon: download skipped (no network or curl unavailable)")
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
/// Choices offered when no configuration file is found on first run
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum SetupChoice {
|
||||
Wizard,
|
||||
Default,
|
||||
}
|
||||
|
||||
/// Parse the first-run setup choice input.
|
||||
///
|
||||
/// Accepts:
|
||||
/// - `""`, `" "`, `"1"`, `"w"`, `"wizard"` → `Wizard` (default)
|
||||
/// - `"2"`, `"d"`, `"default"` → `Default`
|
||||
/// - anything else falls back to `Wizard`
|
||||
pub fn parse_setup_choice_input(input: &str) -> SetupChoice {
|
||||
match input.trim().to_lowercase().as_str() {
|
||||
"2" | "d" | "default" => SetupChoice::Default,
|
||||
_ => SetupChoice::Wizard,
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the first-run prompt asking whether to run the wizard or use defaults.
|
||||
/// Returns the raw user input.
|
||||
pub fn render_setup_choice_prompt() -> Result<String> {
|
||||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||||
let title_style = Style::new().bold(true).foreground(color_blue());
|
||||
let option_style = Style::new().foreground(color_purple());
|
||||
|
||||
println!();
|
||||
println!("{}", title_style.render(" 🚀 Welcome to tmuxido!"));
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" No configuration found. How would you like to get started?")
|
||||
);
|
||||
println!();
|
||||
println!(
|
||||
" {}",
|
||||
option_style
|
||||
.render("1. Run setup wizard — configure paths, cache, and windows interactively")
|
||||
);
|
||||
println!(
|
||||
" {}",
|
||||
option_style.render("2. Use default config — start immediately with sensible defaults")
|
||||
);
|
||||
println!();
|
||||
print!(" {} ", prompt_style.render("❯ Choose (1/2):"));
|
||||
io::stdout().flush().context("Failed to flush stdout")?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin()
|
||||
.read_line(&mut input)
|
||||
.context("Failed to read input")?;
|
||||
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
/// Renders a confirmation message after the default config is written.
|
||||
pub fn render_default_config_saved(config_path: &str) {
|
||||
let success_style = Style::new().bold(true).foreground(color_green());
|
||||
let info_style = Style::new().foreground(color_dark_gray());
|
||||
let path_style = Style::new().bold(true).foreground(color_blue());
|
||||
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
success_style.render(" ✅ Default configuration saved!")
|
||||
);
|
||||
println!(
|
||||
" {} {}",
|
||||
info_style.render("Config:"),
|
||||
path_style.render(config_path)
|
||||
);
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
info_style.render(
|
||||
" ⚙️ Edit it anytime to customise your setup, or run 'tmuxido --setup-shortcut'."
|
||||
)
|
||||
);
|
||||
println!();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
/// Parse comma-separated list into Vec<String>, filtering empty items
|
||||
pub fn parse_comma_separated_list(input: &str) -> Vec<String> {
|
||||
input
|
||||
@ -531,6 +871,50 @@ mod tests {
|
||||
render_config_created(&vec!["~/Projects".to_string()], 5, true, 24, &windows);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_none_for_empty_layout_input() {
|
||||
assert_eq!(parse_layout_input(""), None);
|
||||
assert_eq!(parse_layout_input(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_parse_layout_by_number() {
|
||||
assert_eq!(parse_layout_input("1"), Some("main-horizontal".to_string()));
|
||||
assert_eq!(parse_layout_input("2"), Some("main-vertical".to_string()));
|
||||
assert_eq!(parse_layout_input("3"), Some("tiled".to_string()));
|
||||
assert_eq!(parse_layout_input("4"), Some("even-horizontal".to_string()));
|
||||
assert_eq!(parse_layout_input("5"), Some("even-vertical".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_parse_layout_by_name() {
|
||||
assert_eq!(
|
||||
parse_layout_input("main-horizontal"),
|
||||
Some("main-horizontal".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_layout_input("main-vertical"),
|
||||
Some("main-vertical".to_string())
|
||||
);
|
||||
assert_eq!(parse_layout_input("tiled"), Some("tiled".to_string()));
|
||||
assert_eq!(
|
||||
parse_layout_input("even-horizontal"),
|
||||
Some("even-horizontal".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_layout_input("even-vertical"),
|
||||
Some("even-vertical".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_none_for_invalid_layout_input() {
|
||||
assert_eq!(parse_layout_input("6"), None);
|
||||
assert_eq!(parse_layout_input("0"), None);
|
||||
assert_eq!(parse_layout_input("unknown"), None);
|
||||
assert_eq!(parse_layout_input("horizontal"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_config_created_with_disabled_cache_should_not_panic() {
|
||||
let windows = vec![Window {
|
||||
@ -540,4 +924,93 @@ mod tests {
|
||||
}];
|
||||
render_config_created(&vec!["~/work".to_string()], 3, false, 24, &windows);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_shortcut_unknown_de_should_not_panic() {
|
||||
render_shortcut_unknown_de();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_shortcut_success_should_not_panic() {
|
||||
render_shortcut_success(
|
||||
"Hyprland",
|
||||
"Super+Shift+T",
|
||||
"Added to ~/.config/hypr/bindings.conf",
|
||||
"Reload Hyprland with Super+Shift+R to activate.",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_desktop_integration_success_should_not_panic() {
|
||||
use crate::shortcut::DesktopInstallResult;
|
||||
use std::path::PathBuf;
|
||||
let result = DesktopInstallResult {
|
||||
desktop_path: PathBuf::from("/home/user/.local/share/applications/tmuxido.desktop"),
|
||||
icon_path: PathBuf::from(
|
||||
"/home/user/.local/share/icons/hicolor/96x96/apps/tmuxido.png",
|
||||
),
|
||||
icon_downloaded: true,
|
||||
};
|
||||
render_desktop_integration_success(&result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_desktop_integration_success_without_icon_should_not_panic() {
|
||||
use crate::shortcut::DesktopInstallResult;
|
||||
use std::path::PathBuf;
|
||||
let result = DesktopInstallResult {
|
||||
desktop_path: PathBuf::from("/home/user/.local/share/applications/tmuxido.desktop"),
|
||||
icon_path: PathBuf::from(
|
||||
"/home/user/.local/share/icons/hicolor/96x96/apps/tmuxido.png",
|
||||
),
|
||||
icon_downloaded: false,
|
||||
};
|
||||
render_desktop_integration_success(&result);
|
||||
}
|
||||
|
||||
// ---- SetupChoice / parse_setup_choice_input ----
|
||||
|
||||
#[test]
|
||||
fn should_return_wizard_for_empty_input() {
|
||||
assert_eq!(parse_setup_choice_input(""), SetupChoice::Wizard);
|
||||
assert_eq!(parse_setup_choice_input(" "), SetupChoice::Wizard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_wizard_for_option_1() {
|
||||
assert_eq!(parse_setup_choice_input("1"), SetupChoice::Wizard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_wizard_for_w_aliases() {
|
||||
assert_eq!(parse_setup_choice_input("w"), SetupChoice::Wizard);
|
||||
assert_eq!(parse_setup_choice_input("wizard"), SetupChoice::Wizard);
|
||||
assert_eq!(parse_setup_choice_input("W"), SetupChoice::Wizard);
|
||||
assert_eq!(parse_setup_choice_input("WIZARD"), SetupChoice::Wizard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_default_for_option_2() {
|
||||
assert_eq!(parse_setup_choice_input("2"), SetupChoice::Default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_default_for_d_aliases() {
|
||||
assert_eq!(parse_setup_choice_input("d"), SetupChoice::Default);
|
||||
assert_eq!(parse_setup_choice_input("default"), SetupChoice::Default);
|
||||
assert_eq!(parse_setup_choice_input("D"), SetupChoice::Default);
|
||||
assert_eq!(parse_setup_choice_input("DEFAULT"), SetupChoice::Default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_wizard_for_unknown_input() {
|
||||
assert_eq!(parse_setup_choice_input("banana"), SetupChoice::Wizard);
|
||||
assert_eq!(parse_setup_choice_input("3"), SetupChoice::Wizard);
|
||||
assert_eq!(parse_setup_choice_input("yes"), SetupChoice::Wizard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_default_config_saved_should_not_panic() {
|
||||
render_default_config_saved("/home/user/.config/tmuxido/tmuxido.toml");
|
||||
}
|
||||
}
|
||||
|
||||
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