Compare commits

...

7 Commits
0.8.1 ... main

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

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

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

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

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

- Add `SetupChoice` enum and `parse_setup_choice_input` (pure, tested)
- Add `render_setup_choice_prompt` and `render_default_config_saved` UI helpers
- Extract `Config::write_default_config` and `Config::run_wizard` from
  `ensure_config_exists` for clarity; routing now driven by the user's choice
- 9 new tests covering all branches of `parse_setup_choice_input` and
  the default-config write path
2026-03-01 20:15:30 -03:00
2da5715a34 🔖 chore: bump version to 0.8.3 and commit Cargo.lock
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2026-03-01 20:00:50 -03:00
4263f0379d 🐛 fix: handle space after colon in GitHub API JSON tag_name field
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2026-03-01 19:59:12 -03:00
9 changed files with 524 additions and 356 deletions

View File

@ -4,6 +4,44 @@ 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

235
Cargo.lock generated
View File

@ -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.8.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"

View File

@ -1,6 +1,6 @@
[package]
name = "tmuxido"
version = "0.8.1"
version = "0.10.0"
edition = "2024"
[dev-dependencies]

View File

@ -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)

View File

@ -19,7 +19,7 @@ esac
api_resp=$(curl -sSL \
-H "Accept: application/vnd.github.v3+json" \
"$API_URL/repos/$REPO/releases/latest")
tag=$(printf '%s' "$api_resp" | grep -o '"tag_name":"[^"]*"' | cut -d'"' -f4)
tag=$(printf '%s' "$api_resp" | grep -o '"tag_name": *"[^"]*"' | grep -o '"[^"]*"$' | tr -d '"')
if [ -z "$tag" ]; then
echo "Could not fetch latest release." >&2

View File

@ -96,42 +96,22 @@ 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 to set up a keyboard shortcut (best-effort, non-fatal)
// Offer shortcut and desktop integration regardless of setup mode
if let Err(e) = crate::shortcut::setup_shortcut_wizard() {
eprintln!("Warning: shortcut setup failed: {}", e);
}
// Offer to install .desktop entry + icon (best-effort, non-fatal)
if let Err(e) = crate::shortcut::setup_desktop_integration_wizard() {
eprintln!("Warning: desktop integration failed: {}", e);
}
@ -140,6 +120,44 @@ impl Config {
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();
@ -377,6 +395,35 @@ mod tests {
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#"

View File

@ -55,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,
@ -65,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)
}
@ -196,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"
);
}
}

View File

@ -8,8 +8,8 @@ use tmuxido::deps::ensure_dependencies;
use tmuxido::self_update;
use tmuxido::update_check;
use tmuxido::{
get_projects, launch_tmux_session, setup_desktop_integration_wizard, setup_shortcut_wizard,
show_cache_status,
get_projects, launch_tmux_session, refresh_cache, setup_desktop_integration_wizard,
setup_shortcut_wizard, show_cache_status,
};
#[derive(Parser, Debug)]
@ -41,6 +41,10 @@ struct Args {
/// 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<()> {
@ -51,6 +55,13 @@ 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();
@ -105,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()

131
src/ui.rs
View File

@ -613,6 +613,91 @@ pub fn render_desktop_integration_success(result: &crate::shortcut::DesktopInsta
// ============================================================================
/// 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
@ -882,4 +967,50 @@ mod tests {
};
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");
}
}