forked from cinco/tmuxido
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b05c188477 | ||
|
|
75d66cd47c | ||
|
|
973042ce7d | ||
|
|
0f27bedc94 | ||
|
|
da6311bc53 | ||
|
|
2b1773375a | ||
|
|
36aaa65945 | ||
|
|
10a38a1f85 | ||
|
|
42bdc1d409 | ||
|
|
a592c99375 | ||
|
|
ff6050c718 | ||
|
|
15a11ef79c | ||
|
|
6050cb70f3 | ||
|
|
61f6a9fee3 | ||
|
|
e0da58d114 | ||
|
|
437584aac7 | ||
|
|
960724685c | ||
|
|
639bcdf643 | ||
|
|
ddb4b70234 | ||
|
|
32155bc1d2 | ||
|
|
e4cc280f28 | ||
|
|
868540b92a | ||
|
|
0af46bd6a5 | ||
|
|
ba3f923781 |
+54
-2
@@ -53,7 +53,7 @@ trigger:
|
|||||||
event:
|
event:
|
||||||
- tag
|
- tag
|
||||||
ref:
|
ref:
|
||||||
- refs/tags/v*
|
- refs/tags/[0-9]*
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: build-x86_64
|
- name: build-x86_64
|
||||||
@@ -76,10 +76,20 @@ steps:
|
|||||||
commands:
|
commands:
|
||||||
- apk add --no-cache curl jq
|
- apk add --no-cache curl jq
|
||||||
- |
|
- |
|
||||||
|
# Delete existing release for this tag if present (handles retag scenarios)
|
||||||
|
EXISTING_ID=$(curl -fsSL \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/releases/tags/$DRONE_TAG" \
|
||||||
|
| jq -r '.id // empty')
|
||||||
|
if [ -n "$EXISTING_ID" ]; then
|
||||||
|
curl -fsSL -X DELETE \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/releases/$EXISTING_ID"
|
||||||
|
fi
|
||||||
# Read DRONE_TAG via ENVIRON inside awk to avoid Drone's ${VAR} substitution
|
# Read DRONE_TAG via ENVIRON inside awk to avoid Drone's ${VAR} substitution
|
||||||
# which would replace ${TAG} with an empty string before the shell runs.
|
# which would replace ${TAG} with an empty string before the shell runs.
|
||||||
BODY=$(awk '
|
BODY=$(awk '
|
||||||
BEGIN { tag = ENVIRON["DRONE_TAG"]; gsub(/^v/, "", tag) }
|
BEGIN { tag = ENVIRON["DRONE_TAG"] }
|
||||||
/^## \[/ { in_section = (index($0, "[" tag "]") > 0); next }
|
/^## \[/ { in_section = (index($0, "[" tag "]") > 0); next }
|
||||||
in_section && /^## \[/ { exit }
|
in_section && /^## \[/ { exit }
|
||||||
in_section { print }
|
in_section { print }
|
||||||
@@ -99,3 +109,45 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- build-x86_64
|
- build-x86_64
|
||||||
- build-aarch64
|
- build-aarch64
|
||||||
|
|
||||||
|
- name: publish-github
|
||||||
|
image: alpine
|
||||||
|
environment:
|
||||||
|
GITHUB_TOKEN:
|
||||||
|
from_secret: GITHUB_TOKEN
|
||||||
|
GITHUB_REPO: cinco/tmuxido
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache curl jq
|
||||||
|
- |
|
||||||
|
EXISTING=$(curl -fsSL \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
-H "Accept: application/vnd.github.v3+json" \
|
||||||
|
"https://api.github.com/repos/$GITHUB_REPO/releases/tags/$DRONE_TAG" | jq -r '.id // empty')
|
||||||
|
if [ -n "$EXISTING" ]; then
|
||||||
|
curl -s -X DELETE \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/repos/$GITHUB_REPO/releases/$EXISTING"
|
||||||
|
fi
|
||||||
|
BODY=$(awk '
|
||||||
|
BEGIN { tag = ENVIRON["DRONE_TAG"] }
|
||||||
|
/^## \[/ { in_section = (index($0, "[" tag "]") > 0); next }
|
||||||
|
in_section && /^## \[/ { exit }
|
||||||
|
in_section { print }
|
||||||
|
' CHANGELOG.md)
|
||||||
|
RELEASE_ID=$(curl -fsSL -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
-H "Accept: application/vnd.github.v3+json" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"https://api.github.com/repos/$GITHUB_REPO/releases" \
|
||||||
|
-d "{\"tag_name\":\"$DRONE_TAG\",\"name\":\"$DRONE_TAG\",\"body\":$(echo "$BODY" | jq -Rs .)}" \
|
||||||
|
| jq -r '.id')
|
||||||
|
for FILE in tmuxido-x86_64-linux tmuxido-aarch64-linux; do
|
||||||
|
curl -fsSL -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
"https://uploads.github.com/repos/$GITHUB_REPO/releases/$RELEASE_ID/assets?name=$FILE" \
|
||||||
|
--data-binary @"$FILE"
|
||||||
|
done
|
||||||
|
depends_on:
|
||||||
|
- build-x86_64
|
||||||
|
- build-aarch64
|
||||||
|
|||||||
@@ -4,6 +4,72 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
|
|
||||||
|
## [0.7.0] - 2026-03-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `install.sh` now downloads from GitHub Releases
|
||||||
|
- Self-update now queries the GitHub Releases API for new versions
|
||||||
|
- Releases are published to both Gitea and GitHub
|
||||||
|
|
||||||
|
## [0.6.0] - 2026-03-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Periodic update check: on startup, if `update_check_interval_hours` have elapsed since
|
||||||
|
the last check, tmuxido fetches the latest release tag from the Gitea API and prints a
|
||||||
|
notice when a newer version is available (silent on network failure or no update found)
|
||||||
|
- New `update_check` module (`src/update_check.rs`) with injected fetcher for testability
|
||||||
|
- `update_check_interval_hours` config field (default 24, set to 0 to disable)
|
||||||
|
- Cache file `~/.cache/tmuxido/update_check.json` tracks last-checked timestamp and
|
||||||
|
latest known version across runs
|
||||||
|
|
||||||
|
## [0.5.2] - 2026-03-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Test for `detect_arch` asserting asset name follows `tmuxido-{arch}-linux` format
|
||||||
|
|
||||||
|
## [0.5.1] - 2026-03-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Tmux window creation now targets windows by name instead of numeric index, eliminating
|
||||||
|
"index in use" and "can't find window" errors when `base-index` is not 0
|
||||||
|
- Self-update asset name corrected from `x86_64-linux` to `tmuxido-x86_64-linux` to match
|
||||||
|
what CI actually uploads, fixing 404 on `--update`
|
||||||
|
- CI release pipeline now deletes any existing release for the tag before recreating,
|
||||||
|
preventing 409 Conflict errors on retagged releases
|
||||||
|
|
||||||
|
## [0.5.0] - 2026-03-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Interactive configuration wizard on first run with styled prompts
|
||||||
|
- `lipgloss` dependency for beautiful terminal UI with Tokyo Night theme colors
|
||||||
|
- Emoji-enhanced prompts and feedback during setup
|
||||||
|
- Configure project paths interactively with comma-separated input
|
||||||
|
- Configure `max_depth` for project discovery scanning
|
||||||
|
- Configure cache settings (`cache_enabled`, `cache_ttl_hours`)
|
||||||
|
- Configure default session windows interactively
|
||||||
|
- Configure panes within each window with custom names
|
||||||
|
- Configure startup commands for each pane (e.g., `nvim .`, `npm run dev`)
|
||||||
|
- New `ui` module with styled render functions for all prompts
|
||||||
|
- Comprehensive summary showing all configured settings after setup
|
||||||
|
|
||||||
|
## [0.4.2] - 2026-03-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Version mismatch: bumped Cargo.toml version to match release tag, fixing `--update` false positive
|
||||||
|
|
||||||
|
## [0.4.1] - 2026-03-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Self-update feature (`tmuxido --update`) to update binary from latest GitHub release
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-03-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Self-update feature (`tmuxido --update`) to update binary from latest GitHub release
|
||||||
|
- New `self_update` module with version comparison and atomic binary replacement
|
||||||
|
- `--update` CLI flag for in-place binary updates
|
||||||
|
- Backup and rollback mechanism if update fails
|
||||||
|
|
||||||
## [0.3.0] - 2026-03-01
|
## [0.3.0] - 2026-03-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -53,3 +119,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Initial release of tmuxido
|
- Initial release of tmuxido
|
||||||
|
|
||||||
|
|||||||
Generated
+369
-3
@@ -58,12 +58,33 @@ version = "1.0.100"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "approx"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "autocfg"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.10.0"
|
version = "2.10.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "by_address"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -116,6 +137,64 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "convert_case"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-segmentation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossterm"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"crossterm_winapi",
|
||||||
|
"derive_more",
|
||||||
|
"document-features",
|
||||||
|
"mio",
|
||||||
|
"parking_lot",
|
||||||
|
"rustix",
|
||||||
|
"signal-hook",
|
||||||
|
"signal-hook-mio",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossterm_winapi"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_more"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
|
||||||
|
dependencies = [
|
||||||
|
"derive_more-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_more-impl"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||||
|
dependencies = [
|
||||||
|
"convert_case",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustc_version",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dirs"
|
name = "dirs"
|
||||||
version = "5.0.1"
|
version = "5.0.1"
|
||||||
@@ -158,6 +237,15 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "document-features"
|
||||||
|
version = "0.2.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||||
|
dependencies = [
|
||||||
|
"litrs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -174,6 +262,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fast-srgb8"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
@@ -269,9 +363,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.177"
|
version = "0.2.182"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
|
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
@@ -289,6 +383,33 @@ version = "0.11.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lipgloss"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12c1d116ae421d84dfea8bacb5d5fcce330d8b3f03a4867cd1e4860eecd94fb4"
|
||||||
|
dependencies = [
|
||||||
|
"crossterm",
|
||||||
|
"palette",
|
||||||
|
"strip-ansi-escapes",
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "litrs"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lock_api"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||||
|
dependencies = [
|
||||||
|
"scopeguard",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.29"
|
version = "0.4.29"
|
||||||
@@ -301,6 +422,27 @@ version = "2.7.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"wasi",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-traits"
|
||||||
|
version = "0.2.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.3"
|
version = "1.21.3"
|
||||||
@@ -319,6 +461,95 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "palette"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6"
|
||||||
|
dependencies = [
|
||||||
|
"approx",
|
||||||
|
"fast-srgb8",
|
||||||
|
"palette_derive",
|
||||||
|
"phf",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "palette_derive"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30"
|
||||||
|
dependencies = [
|
||||||
|
"by_address",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking_lot"
|
||||||
|
version = "0.12.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||||
|
dependencies = [
|
||||||
|
"lock_api",
|
||||||
|
"parking_lot_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking_lot_core"
|
||||||
|
version = "0.9.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"redox_syscall",
|
||||||
|
"smallvec",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||||
|
dependencies = [
|
||||||
|
"phf_macros",
|
||||||
|
"phf_shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_generator"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||||
|
dependencies = [
|
||||||
|
"phf_shared",
|
||||||
|
"rand",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_macros"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||||
|
dependencies = [
|
||||||
|
"phf_generator",
|
||||||
|
"phf_shared",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_shared"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||||
|
dependencies = [
|
||||||
|
"siphasher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prettyplease"
|
name = "prettyplease"
|
||||||
version = "0.2.37"
|
version = "0.2.37"
|
||||||
@@ -353,6 +584,30 @@ version = "5.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.8.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_syscall"
|
||||||
|
version = "0.5.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_users"
|
name = "redox_users"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
@@ -375,6 +630,15 @@ dependencies = [
|
|||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc_version"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||||
|
dependencies = [
|
||||||
|
"semver",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.3"
|
version = "1.1.3"
|
||||||
@@ -403,6 +667,12 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scopeguard"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "semver"
|
name = "semver"
|
||||||
version = "1.0.27"
|
version = "1.0.27"
|
||||||
@@ -470,6 +740,58 @@ dependencies = [
|
|||||||
"dirs 6.0.0",
|
"dirs 6.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook"
|
||||||
|
version = "0.3.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"signal-hook-registry",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-mio"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"mio",
|
||||||
|
"signal-hook",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-registry"
|
||||||
|
version = "1.4.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||||
|
dependencies = [
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "siphasher"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smallvec"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strip-ansi-escapes"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025"
|
||||||
|
dependencies = [
|
||||||
|
"vte",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -542,11 +864,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tmuxido"
|
name = "tmuxido"
|
||||||
version = "0.2.4"
|
version = "0.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
|
"lipgloss",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"shellexpand",
|
"shellexpand",
|
||||||
@@ -602,6 +925,18 @@ version = "1.0.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06"
|
checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-segmentation"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
@@ -614,6 +949,15 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vte"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "walkdir"
|
name = "walkdir"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -682,6 +1026,22 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-i686-pc-windows-gnu",
|
||||||
|
"winapi-x86_64-pc-windows-gnu",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-i686-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi-util"
|
name = "winapi-util"
|
||||||
version = "0.1.11"
|
version = "0.1.11"
|
||||||
@@ -691,6 +1051,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tmuxido"
|
name = "tmuxido"
|
||||||
version = "0.2.4"
|
version = "0.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
@@ -15,3 +15,4 @@ walkdir = "2.4"
|
|||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
shellexpand = "3.1"
|
shellexpand = "3.1"
|
||||||
clap = { version = "4.5", features = ["derive"] }
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
lipgloss = "0.1"
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs/assets/tmuxido-logo.png" alt="tmuxido logo" width="200"/>
|
<img src="docs/assets/tmuxido-logo.png" alt="tmuxido logo" width="200"/>
|
||||||
</div>
|
</div>
|
||||||
|
<div align="center">
|
||||||
# tmuxido
|
|
||||||
|
|
||||||
[](https://drone.cincoeuzebio.com/cinco/Tmuxido)
|
[](https://drone.cincoeuzebio.com/cinco/Tmuxido)
|
||||||
[](https://drone.cincoeuzebio.com/cinco/Tmuxido)
|
[](https://drone.cincoeuzebio.com/cinco/Tmuxido)
|
||||||
[](https://git.cincoeuzebio.com/cinco/Tmuxido/releases)
|
[](https://git.cincoeuzebio.com/cinco/Tmuxido/releases)
|
||||||

|

|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
# tmuxido
|
||||||
|
|
||||||
A Rust-based tool to quickly find and open projects in tmux using fzf. No external dependencies except tmux and fzf!
|
A Rust-based tool to quickly find and open projects in tmux using fzf. No external dependencies except tmux and fzf!
|
||||||
|
|
||||||
@@ -21,6 +24,7 @@ A Rust-based tool to quickly find and open projects in tmux using fzf. No extern
|
|||||||
- TOML-based configuration
|
- TOML-based configuration
|
||||||
- Smart caching system for fast subsequent runs
|
- Smart caching system for fast subsequent runs
|
||||||
- Configurable cache TTL
|
- Configurable cache TTL
|
||||||
|
- Self-update capability (`tmuxido --update`)
|
||||||
- Zero external dependencies (except tmux and fzf)
|
- Zero external dependencies (except tmux and fzf)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
@@ -97,6 +101,11 @@ Check cache status:
|
|||||||
tmuxido --cache-status
|
tmuxido --cache-status
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Update tmuxido to the latest version:
|
||||||
|
```bash
|
||||||
|
tmuxido --update
|
||||||
|
```
|
||||||
|
|
||||||
View help:
|
View help:
|
||||||
```bash
|
```bash
|
||||||
tmuxido --help
|
tmuxido --help
|
||||||
@@ -161,3 +170,15 @@ Each window can have multiple panes with commands that run automatically:
|
|||||||
- First pane is the main window pane
|
- First pane is the main window pane
|
||||||
- Additional panes are created by splitting
|
- Additional panes are created by splitting
|
||||||
- Empty panes array = just open the window in the project directory
|
- Empty panes array = just open the window in the project directory
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<a href="https://github.com/cinco">
|
||||||
|
<img src="https://github.com/cinco.png" width="100" height="100" style="border-radius: 50%;" alt="Cinco avatar"/>
|
||||||
|
</a>
|
||||||
|
<br><br>
|
||||||
|
<strong>Cinco</strong>
|
||||||
|
<br>
|
||||||
|
<a href="https://github.com/cinco">@cinco</a>
|
||||||
|
</div>
|
||||||
|
|||||||
+7
-4
@@ -1,8 +1,9 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
REPO="cinco/Tmuxido"
|
REPO="cinco/tmuxido"
|
||||||
BASE_URL="https://git.cincoeuzebio.com"
|
BASE_URL="https://github.com"
|
||||||
|
API_URL="https://api.github.com"
|
||||||
INSTALL_DIR="$HOME/.local/bin"
|
INSTALL_DIR="$HOME/.local/bin"
|
||||||
|
|
||||||
arch=$(uname -m)
|
arch=$(uname -m)
|
||||||
@@ -12,8 +13,10 @@ case "$arch" in
|
|||||||
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;;
|
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
tag=$(curl -fsSL "$BASE_URL/api/v1/repos/$REPO/releases?limit=1&page=1" \
|
tag=$(curl -fsSL \
|
||||||
| grep -o '"tag_name":"[^"]*"' | head -1 | cut -d'"' -f4)
|
-H "Accept: application/vnd.github.v3+json" \
|
||||||
|
"$API_URL/repos/$REPO/releases/latest" \
|
||||||
|
| grep -o '"tag_name":"[^"]*"' | cut -d'"' -f4)
|
||||||
|
|
||||||
[ -z "$tag" ] && { echo "Could not fetch latest release" >&2; exit 1; }
|
[ -z "$tag" ] && { echo "Could not fetch latest release" >&2; exit 1; }
|
||||||
|
|
||||||
|
|||||||
+230
-5
@@ -4,6 +4,7 @@ use std::fs;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::session::SessionConfig;
|
use crate::session::SessionConfig;
|
||||||
|
use crate::ui;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
@@ -14,6 +15,8 @@ pub struct Config {
|
|||||||
pub cache_enabled: bool,
|
pub cache_enabled: bool,
|
||||||
#[serde(default = "default_cache_ttl_hours")]
|
#[serde(default = "default_cache_ttl_hours")]
|
||||||
pub cache_ttl_hours: u64,
|
pub cache_ttl_hours: u64,
|
||||||
|
#[serde(default = "default_update_check_interval_hours")]
|
||||||
|
pub update_check_interval_hours: u64,
|
||||||
#[serde(default = "default_session_config")]
|
#[serde(default = "default_session_config")]
|
||||||
pub default_session: SessionConfig,
|
pub default_session: SessionConfig,
|
||||||
}
|
}
|
||||||
@@ -30,6 +33,10 @@ fn default_cache_ttl_hours() -> u64 {
|
|||||||
24
|
24
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_update_check_interval_hours() -> u64 {
|
||||||
|
24
|
||||||
|
}
|
||||||
|
|
||||||
fn default_session_config() -> SessionConfig {
|
fn default_session_config() -> SessionConfig {
|
||||||
use crate::session::Window;
|
use crate::session::Window;
|
||||||
|
|
||||||
@@ -89,20 +96,134 @@ impl Config {
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let default_config = Self::default_config();
|
// Run interactive configuration wizard
|
||||||
let toml_string = toml::to_string_pretty(&default_config)
|
let paths = Self::prompt_for_paths()?;
|
||||||
.context("Failed to serialize default config")?;
|
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: 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(|| {
|
fs::write(&config_path, toml_string).with_context(|| {
|
||||||
format!("Failed to write config file: {}", config_path.display())
|
format!("Failed to write config file: {}", config_path.display())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
eprintln!("Created default config at: {}", config_path.display());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(config_path)
|
Ok(config_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn prompt_for_paths() -> Result<Vec<String>> {
|
||||||
|
// Render styled welcome banner
|
||||||
|
ui::render_welcome_banner();
|
||||||
|
|
||||||
|
// Get input with styled prompt
|
||||||
|
let input = ui::render_paths_prompt()?;
|
||||||
|
let paths = Self::parse_paths_input(&input);
|
||||||
|
|
||||||
|
if paths.is_empty() {
|
||||||
|
ui::render_fallback_message();
|
||||||
|
Ok(vec![
|
||||||
|
dirs::home_dir()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.join("Projects")
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string(),
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
Ok(paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_for_max_depth() -> Result<usize> {
|
||||||
|
ui::render_section_header("Scan Settings");
|
||||||
|
let input = ui::render_max_depth_prompt()?;
|
||||||
|
Ok(ui::parse_max_depth_input(&input).unwrap_or(5))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_for_cache_enabled() -> Result<bool> {
|
||||||
|
ui::render_section_header("Cache Settings");
|
||||||
|
let input = ui::render_cache_enabled_prompt()?;
|
||||||
|
Ok(ui::parse_cache_enabled_input(&input).unwrap_or(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_for_cache_ttl() -> Result<u64> {
|
||||||
|
let input = ui::render_cache_ttl_prompt()?;
|
||||||
|
Ok(ui::parse_cache_ttl_input(&input).unwrap_or(24))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_for_windows() -> Result<Vec<crate::session::Window>> {
|
||||||
|
ui::render_section_header("Default Session");
|
||||||
|
let input = ui::render_windows_prompt()?;
|
||||||
|
|
||||||
|
let window_names = ui::parse_comma_separated_list(&input);
|
||||||
|
|
||||||
|
let names = if window_names.is_empty() {
|
||||||
|
vec!["editor".to_string(), "terminal".to_string()]
|
||||||
|
} else {
|
||||||
|
window_names
|
||||||
|
};
|
||||||
|
|
||||||
|
// Configure panes for each window
|
||||||
|
let mut windows = Vec::new();
|
||||||
|
for name in names {
|
||||||
|
let panes = Self::prompt_for_panes(&name)?;
|
||||||
|
windows.push(crate::session::Window {
|
||||||
|
name,
|
||||||
|
panes,
|
||||||
|
layout: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(windows)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_for_panes(window_name: &str) -> Result<Vec<String>> {
|
||||||
|
let input = ui::render_panes_prompt(window_name)?;
|
||||||
|
|
||||||
|
let pane_names = ui::parse_comma_separated_list(&input);
|
||||||
|
|
||||||
|
if pane_names.is_empty() {
|
||||||
|
// Single pane, no commands
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask for commands for each pane
|
||||||
|
let mut panes = Vec::new();
|
||||||
|
for pane_name in pane_names {
|
||||||
|
let command = ui::render_pane_command_prompt(&pane_name)?;
|
||||||
|
panes.push(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(panes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_paths_input(input: &str) -> Vec<String> {
|
||||||
|
input
|
||||||
|
.trim()
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn default_config() -> Self {
|
fn default_config() -> Self {
|
||||||
Config {
|
Config {
|
||||||
paths: vec![
|
paths: vec![
|
||||||
@@ -115,6 +236,7 @@ impl Config {
|
|||||||
max_depth: 5,
|
max_depth: 5,
|
||||||
cache_enabled: true,
|
cache_enabled: true,
|
||||||
cache_ttl_hours: 24,
|
cache_ttl_hours: 24,
|
||||||
|
update_check_interval_hours: default_update_check_interval_hours(),
|
||||||
default_session: default_session_config(),
|
default_session: default_session_config(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,6 +253,7 @@ mod tests {
|
|||||||
assert_eq!(config.max_depth, 5);
|
assert_eq!(config.max_depth, 5);
|
||||||
assert!(config.cache_enabled);
|
assert!(config.cache_enabled);
|
||||||
assert_eq!(config.cache_ttl_hours, 24);
|
assert_eq!(config.cache_ttl_hours, 24);
|
||||||
|
assert_eq!(config.update_check_interval_hours, 24);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -153,4 +276,106 @@ mod tests {
|
|||||||
let result: Result<Config, _> = toml::from_str("not valid toml ]][[");
|
let result: Result<Config, _> = toml::from_str("not valid toml ]][[");
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_parse_single_path() {
|
||||||
|
let input = "~/Projects";
|
||||||
|
let paths = Config::parse_paths_input(input);
|
||||||
|
assert_eq!(paths, vec!["~/Projects"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_parse_multiple_paths_with_commas() {
|
||||||
|
let input = "~/Projects, ~/work, ~/repos";
|
||||||
|
let paths = Config::parse_paths_input(input);
|
||||||
|
assert_eq!(paths, vec!["~/Projects", "~/work", "~/repos"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_trim_whitespace_from_paths() {
|
||||||
|
let input = " ~/Projects , ~/work ";
|
||||||
|
let paths = Config::parse_paths_input(input);
|
||||||
|
assert_eq!(paths, vec!["~/Projects", "~/work"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_empty_vec_for_empty_input() {
|
||||||
|
let input = "";
|
||||||
|
let paths = Config::parse_paths_input(input);
|
||||||
|
assert!(paths.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_empty_vec_for_whitespace_only() {
|
||||||
|
let input = " ";
|
||||||
|
let paths = Config::parse_paths_input(input);
|
||||||
|
assert!(paths.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_handle_empty_parts_between_commas() {
|
||||||
|
let input = "~/Projects,,~/work";
|
||||||
|
let paths = Config::parse_paths_input(input);
|
||||||
|
assert_eq!(paths, vec!["~/Projects", "~/work"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_use_ui_parse_functions_for_max_depth() {
|
||||||
|
// Test that our UI parsing produces expected results
|
||||||
|
assert_eq!(ui::parse_max_depth_input(""), None);
|
||||||
|
assert_eq!(ui::parse_max_depth_input("5"), Some(5));
|
||||||
|
assert_eq!(ui::parse_max_depth_input("invalid"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_use_ui_parse_functions_for_cache_enabled() {
|
||||||
|
assert_eq!(ui::parse_cache_enabled_input(""), None);
|
||||||
|
assert_eq!(ui::parse_cache_enabled_input("y"), Some(true));
|
||||||
|
assert_eq!(ui::parse_cache_enabled_input("n"), Some(false));
|
||||||
|
assert_eq!(ui::parse_cache_enabled_input("maybe"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_use_ui_parse_functions_for_cache_ttl() {
|
||||||
|
assert_eq!(ui::parse_cache_ttl_input(""), None);
|
||||||
|
assert_eq!(ui::parse_cache_ttl_input("24"), Some(24));
|
||||||
|
assert_eq!(ui::parse_cache_ttl_input("invalid"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_use_ui_parse_functions_for_window_names() {
|
||||||
|
let result = ui::parse_comma_separated_list("editor, terminal, server");
|
||||||
|
assert_eq!(result, vec!["editor", "terminal", "server"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_parse_config_with_windows_and_panes() {
|
||||||
|
let toml_str = r#"
|
||||||
|
paths = ["/projects"]
|
||||||
|
max_depth = 3
|
||||||
|
cache_enabled = true
|
||||||
|
cache_ttl_hours = 12
|
||||||
|
|
||||||
|
[default_session]
|
||||||
|
[[default_session.windows]]
|
||||||
|
name = "editor"
|
||||||
|
panes = ["nvim .", "git status"]
|
||||||
|
|
||||||
|
[[default_session.windows]]
|
||||||
|
name = "terminal"
|
||||||
|
panes = []
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
assert_eq!(config.paths, vec!["/projects"]);
|
||||||
|
assert_eq!(config.max_depth, 3);
|
||||||
|
assert!(config.cache_enabled);
|
||||||
|
assert_eq!(config.cache_ttl_hours, 12);
|
||||||
|
assert_eq!(config.default_session.windows.len(), 2);
|
||||||
|
assert_eq!(config.default_session.windows[0].name, "editor");
|
||||||
|
assert_eq!(config.default_session.windows[0].panes.len(), 2);
|
||||||
|
assert_eq!(config.default_session.windows[0].panes[0], "nvim .");
|
||||||
|
assert_eq!(config.default_session.windows[0].panes[1], "git status");
|
||||||
|
assert_eq!(config.default_session.windows[1].name, "terminal");
|
||||||
|
assert!(config.default_session.windows[1].panes.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+273
-8
@@ -1,7 +1,10 @@
|
|||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod deps;
|
pub mod deps;
|
||||||
|
pub mod self_update;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
pub mod ui;
|
||||||
|
pub mod update_check;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use cache::ProjectCache;
|
use cache::ProjectCache;
|
||||||
@@ -37,28 +40,45 @@ pub fn show_cache_status(config: &Config) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_projects(config: &Config, force_refresh: bool) -> Result<Vec<PathBuf>> {
|
pub fn get_projects(config: &Config, force_refresh: bool) -> Result<Vec<PathBuf>> {
|
||||||
|
get_projects_internal(
|
||||||
|
config,
|
||||||
|
force_refresh,
|
||||||
|
&ProjectCache::load,
|
||||||
|
&|cache| cache.save(),
|
||||||
|
&scan_all_roots,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
fn get_projects_internal(
|
||||||
|
config: &Config,
|
||||||
|
force_refresh: bool,
|
||||||
|
cache_loader: &dyn Fn() -> Result<Option<ProjectCache>>,
|
||||||
|
cache_saver: &dyn Fn(&ProjectCache) -> Result<()>,
|
||||||
|
scanner: &dyn Fn(&Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>,
|
||||||
|
) -> Result<Vec<PathBuf>> {
|
||||||
if !config.cache_enabled || force_refresh {
|
if !config.cache_enabled || force_refresh {
|
||||||
let (projects, fingerprints) = scan_all_roots(config)?;
|
let (projects, fingerprints) = scanner(config)?;
|
||||||
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
||||||
cache.save()?;
|
cache_saver(&cache)?;
|
||||||
eprintln!("Cache updated with {} projects", projects.len());
|
eprintln!("Cache updated with {} projects", projects.len());
|
||||||
return Ok(projects);
|
return Ok(projects);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(mut cache) = ProjectCache::load()? {
|
if let Some(mut cache) = cache_loader()? {
|
||||||
// Cache no formato antigo (sem dir_mtimes) → atualizar com rescan completo
|
// Cache no formato antigo (sem dir_mtimes) → atualizar com rescan completo
|
||||||
if cache.dir_mtimes.is_empty() {
|
if cache.dir_mtimes.is_empty() {
|
||||||
eprintln!("Upgrading cache, scanning for projects...");
|
eprintln!("Upgrading cache, scanning for projects...");
|
||||||
let (projects, fingerprints) = scan_all_roots(config)?;
|
let (projects, fingerprints) = scanner(config)?;
|
||||||
let new_cache = ProjectCache::new(projects.clone(), fingerprints);
|
let new_cache = ProjectCache::new(projects.clone(), fingerprints);
|
||||||
new_cache.save()?;
|
cache_saver(&new_cache)?;
|
||||||
eprintln!("Cache updated with {} projects", projects.len());
|
eprintln!("Cache updated with {} projects", projects.len());
|
||||||
return Ok(projects);
|
return Ok(projects);
|
||||||
}
|
}
|
||||||
|
|
||||||
let changed = cache.validate_and_update(&|root| scan_from_root(root, config))?;
|
let changed = cache.validate_and_update(&|root| scan_from_root(root, config))?;
|
||||||
if changed {
|
if changed {
|
||||||
cache.save()?;
|
cache_saver(&cache)?;
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Cache updated incrementally ({} projects)",
|
"Cache updated incrementally ({} projects)",
|
||||||
cache.projects.len()
|
cache.projects.len()
|
||||||
@@ -71,9 +91,9 @@ pub fn get_projects(config: &Config, force_refresh: bool) -> Result<Vec<PathBuf>
|
|||||||
|
|
||||||
// Sem cache ainda — scan completo inicial
|
// Sem cache ainda — scan completo inicial
|
||||||
eprintln!("No cache found, scanning for projects...");
|
eprintln!("No cache found, scanning for projects...");
|
||||||
let (projects, fingerprints) = scan_all_roots(config)?;
|
let (projects, fingerprints) = scanner(config)?;
|
||||||
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
||||||
cache.save()?;
|
cache_saver(&cache)?;
|
||||||
eprintln!("Cache updated with {} projects", projects.len());
|
eprintln!("Cache updated with {} projects", projects.len());
|
||||||
Ok(projects)
|
Ok(projects)
|
||||||
}
|
}
|
||||||
@@ -161,3 +181,248 @@ pub fn launch_tmux_session(selected: &Path, config: &Config) -> Result<()> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
fn create_test_config(cache_enabled: bool) -> Config {
|
||||||
|
Config {
|
||||||
|
paths: vec!["/tmp/test".to_string()],
|
||||||
|
max_depth: 3,
|
||||||
|
cache_enabled,
|
||||||
|
cache_ttl_hours: 24,
|
||||||
|
update_check_interval_hours: 24,
|
||||||
|
default_session: session::SessionConfig { windows: vec![] },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 scanner_called = RefCell::new(false);
|
||||||
|
let saver_called = RefCell::new(false);
|
||||||
|
|
||||||
|
let result = get_projects_internal(
|
||||||
|
&config,
|
||||||
|
false,
|
||||||
|
&|| panic!("should not load cache when disabled"),
|
||||||
|
&|_| {
|
||||||
|
*saver_called.borrow_mut() = true;
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
&|_| {
|
||||||
|
*scanner_called.borrow_mut() = true;
|
||||||
|
Ok((expected_projects.clone(), fingerprints.clone()))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(scanner_called.into_inner());
|
||||||
|
assert!(saver_called.into_inner());
|
||||||
|
assert_eq!(result.unwrap(), projects);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 scanner_called = RefCell::new(false);
|
||||||
|
let saver_called = RefCell::new(false);
|
||||||
|
|
||||||
|
let result = get_projects_internal(
|
||||||
|
&config,
|
||||||
|
true,
|
||||||
|
&|| panic!("should not load cache when force refresh"),
|
||||||
|
&|_| {
|
||||||
|
*saver_called.borrow_mut() = true;
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
&|_| {
|
||||||
|
*scanner_called.borrow_mut() = true;
|
||||||
|
Ok((expected_projects.clone(), fingerprints.clone()))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(scanner_called.into_inner());
|
||||||
|
assert!(saver_called.into_inner());
|
||||||
|
assert_eq!(result.unwrap(), projects);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
let scanner_called = RefCell::new(false);
|
||||||
|
let saver_called = RefCell::new(false);
|
||||||
|
|
||||||
|
let result = get_projects_internal(
|
||||||
|
&config,
|
||||||
|
false,
|
||||||
|
&|| {
|
||||||
|
*loader_called.borrow_mut() = true;
|
||||||
|
Ok(None)
|
||||||
|
},
|
||||||
|
&|_| {
|
||||||
|
*saver_called.borrow_mut() = true;
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
&|_| {
|
||||||
|
*scanner_called.borrow_mut() = true;
|
||||||
|
Ok((expected_projects.clone(), fingerprints.clone()))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(loader_called.into_inner());
|
||||||
|
assert!(scanner_called.into_inner());
|
||||||
|
assert!(saver_called.into_inner());
|
||||||
|
assert_eq!(result.unwrap(), projects);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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)]);
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
&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()))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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)]);
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
&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")
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
&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"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+14
@@ -5,6 +5,8 @@ use std::path::PathBuf;
|
|||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use tmuxido::config::Config;
|
use tmuxido::config::Config;
|
||||||
use tmuxido::deps::ensure_dependencies;
|
use tmuxido::deps::ensure_dependencies;
|
||||||
|
use tmuxido::self_update;
|
||||||
|
use tmuxido::update_check;
|
||||||
use tmuxido::{get_projects, launch_tmux_session, show_cache_status};
|
use tmuxido::{get_projects, launch_tmux_session, show_cache_status};
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
@@ -24,11 +26,20 @@ struct Args {
|
|||||||
/// Show cache status and exit
|
/// Show cache status and exit
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
cache_status: bool,
|
cache_status: bool,
|
||||||
|
|
||||||
|
/// Update tmuxido to the latest version
|
||||||
|
#[arg(long)]
|
||||||
|
update: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
|
||||||
|
// Handle self-update before anything else
|
||||||
|
if args.update {
|
||||||
|
return self_update::self_update();
|
||||||
|
}
|
||||||
|
|
||||||
// Check that fzf and tmux are installed; offer to install if missing
|
// Check that fzf and tmux are installed; offer to install if missing
|
||||||
ensure_dependencies()?;
|
ensure_dependencies()?;
|
||||||
|
|
||||||
@@ -38,6 +49,9 @@ fn main() -> Result<()> {
|
|||||||
// Load config
|
// Load config
|
||||||
let config = Config::load()?;
|
let config = Config::load()?;
|
||||||
|
|
||||||
|
// Periodic update check (silent on failure or no update)
|
||||||
|
update_check::check_and_notify(&config);
|
||||||
|
|
||||||
// Handle cache status command
|
// Handle cache status command
|
||||||
if args.cache_status {
|
if args.cache_status {
|
||||||
show_cache_status(&config)?;
|
show_cache_status(&config)?;
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
const REPO: &str = "cinco/tmuxido";
|
||||||
|
const BASE_URL: &str = "https://github.com";
|
||||||
|
const API_BASE: &str = "https://api.github.com";
|
||||||
|
|
||||||
|
/// Check if running from cargo (development mode)
|
||||||
|
fn is_dev_build() -> bool {
|
||||||
|
option_env!("CARGO_PKG_NAME").is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current version from cargo
|
||||||
|
pub fn current_version() -> &'static str {
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect system architecture
|
||||||
|
fn detect_arch() -> Result<&'static str> {
|
||||||
|
let arch = std::env::consts::ARCH;
|
||||||
|
match arch {
|
||||||
|
"x86_64" => Ok("tmuxido-x86_64-linux"),
|
||||||
|
"aarch64" => Ok("tmuxido-aarch64-linux"),
|
||||||
|
_ => Err(anyhow::anyhow!("Unsupported architecture: {}", arch)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse tag_name from a GitHub releases/latest JSON response
|
||||||
|
fn parse_latest_tag(response: &str) -> Result<String> {
|
||||||
|
let tag: serde_json::Value =
|
||||||
|
serde_json::from_str(response).context("Failed to parse release API response")?;
|
||||||
|
tag.get("tag_name")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.map(|t| t.to_string())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Could not extract tag_name from release"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch latest release tag from GitHub API
|
||||||
|
pub(crate) fn fetch_latest_tag() -> Result<String> {
|
||||||
|
let url = format!("{}/repos/{}/releases/latest", API_BASE, REPO);
|
||||||
|
|
||||||
|
let output = Command::new("curl")
|
||||||
|
.args([
|
||||||
|
"-fsSL",
|
||||||
|
"-H",
|
||||||
|
"Accept: application/vnd.github.v3+json",
|
||||||
|
&url,
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.context("Failed to execute curl. Make sure curl is installed.")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Failed to fetch latest release: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_latest_tag(&String::from_utf8_lossy(&output.stdout))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get path to current executable
|
||||||
|
fn get_current_exe() -> Result<PathBuf> {
|
||||||
|
std::env::current_exe().context("Failed to get current executable path")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download binary to a temporary location
|
||||||
|
fn download_binary(tag: &str, arch: &str, temp_path: &std::path::Path) -> Result<()> {
|
||||||
|
let url = format!("{}/{}/releases/download/{}/{}", BASE_URL, REPO, tag, arch);
|
||||||
|
|
||||||
|
println!("Downloading {}...", url);
|
||||||
|
|
||||||
|
let output = Command::new("curl")
|
||||||
|
.args(["-fsSL", &url, "-o", &temp_path.to_string_lossy()])
|
||||||
|
.output()
|
||||||
|
.context("Failed to execute curl for download")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Failed to download binary: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make executable
|
||||||
|
let mut perms = std::fs::metadata(temp_path)?.permissions();
|
||||||
|
perms.set_mode(0o755);
|
||||||
|
std::fs::set_permissions(temp_path, perms)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform self-update
|
||||||
|
pub fn self_update() -> Result<()> {
|
||||||
|
if is_dev_build() {
|
||||||
|
println!("Development build detected. Skipping self-update.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = current_version();
|
||||||
|
println!("Current version: {}", current);
|
||||||
|
|
||||||
|
let latest = fetch_latest_tag()?;
|
||||||
|
let latest_clean = latest.trim_start_matches('v');
|
||||||
|
println!("Latest version: {}", latest);
|
||||||
|
|
||||||
|
// Compare versions (simple string comparison for semver without 'v' prefix)
|
||||||
|
if latest_clean == current {
|
||||||
|
println!("Already up to date!");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if latest is actually newer
|
||||||
|
match version_compare(latest_clean, current) {
|
||||||
|
std::cmp::Ordering::Less => {
|
||||||
|
println!("Current version is newer than release. Skipping update.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
std::cmp::Ordering::Equal => {
|
||||||
|
println!("Already up to date!");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let arch = detect_arch()?;
|
||||||
|
let exe_path = get_current_exe()?;
|
||||||
|
|
||||||
|
// Create temporary file in same directory as target (for atomic rename)
|
||||||
|
let exe_dir = exe_path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Could not determine executable directory"))?;
|
||||||
|
let temp_path = exe_dir.join(".tmuxido.new");
|
||||||
|
|
||||||
|
println!("Downloading update...");
|
||||||
|
download_binary(&latest, arch, &temp_path)?;
|
||||||
|
|
||||||
|
// Verify the downloaded binary works
|
||||||
|
let verify = Command::new(&temp_path).arg("--version").output();
|
||||||
|
if let Err(e) = verify {
|
||||||
|
let _ = std::fs::remove_file(&temp_path);
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Downloaded binary verification failed: {}",
|
||||||
|
e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic replace: rename old to .old, rename new to target
|
||||||
|
let backup_path = exe_path.with_extension("old");
|
||||||
|
|
||||||
|
// Remove old backup if exists
|
||||||
|
let _ = std::fs::remove_file(&backup_path);
|
||||||
|
|
||||||
|
// Rename current to backup
|
||||||
|
std::fs::rename(&exe_path, &backup_path)
|
||||||
|
.context("Failed to backup current binary (is tmuxido running?)")?;
|
||||||
|
|
||||||
|
// Move new to current location
|
||||||
|
if let Err(e) = std::fs::rename(&temp_path, &exe_path) {
|
||||||
|
// Restore backup on failure
|
||||||
|
let _ = std::fs::rename(&backup_path, &exe_path);
|
||||||
|
return Err(anyhow::anyhow!("Failed to install new binary: {}", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove backup on success
|
||||||
|
let _ = std::fs::remove_file(&backup_path);
|
||||||
|
|
||||||
|
println!("Successfully updated to {}!", latest);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare two semver versions
|
||||||
|
pub(crate) fn version_compare(a: &str, b: &str) -> std::cmp::Ordering {
|
||||||
|
let parse = |s: &str| {
|
||||||
|
s.split('.')
|
||||||
|
.filter_map(|n| n.parse::<u32>().ok())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
let a_parts = parse(a);
|
||||||
|
let b_parts = parse(b);
|
||||||
|
|
||||||
|
for (a_part, b_part) in a_parts.iter().zip(b_parts.iter()) {
|
||||||
|
match a_part.cmp(b_part) {
|
||||||
|
std::cmp::Ordering::Equal => continue,
|
||||||
|
other => return other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a_parts.len().cmp(&b_parts.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_detect_current_version() {
|
||||||
|
let version = current_version();
|
||||||
|
// Version should be non-empty and contain dots
|
||||||
|
assert!(!version.is_empty());
|
||||||
|
assert!(version.contains('.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_prefix_arch_asset_with_tmuxido() {
|
||||||
|
let arch = detect_arch().expect("should detect supported arch");
|
||||||
|
assert!(
|
||||||
|
arch.starts_with("tmuxido-"),
|
||||||
|
"asset name must start with 'tmuxido-', got: {arch}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
arch.ends_with("-linux"),
|
||||||
|
"asset name must end with '-linux', got: {arch}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_parse_tag_from_github_latest_release_response() {
|
||||||
|
let json = r#"{"tag_name":"0.7.0","name":"0.7.0","body":"release notes"}"#;
|
||||||
|
assert_eq!(parse_latest_tag(json).unwrap(), "0.7.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_error_when_tag_name_missing() {
|
||||||
|
let json = r#"{"name":"0.7.0","body":"no tag_name field"}"#;
|
||||||
|
assert!(parse_latest_tag(json).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_error_when_response_is_invalid_json() {
|
||||||
|
assert!(parse_latest_tag("not valid json").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_compare_versions_correctly() {
|
||||||
|
assert_eq!(
|
||||||
|
version_compare("0.3.0", "0.2.4"),
|
||||||
|
std::cmp::Ordering::Greater
|
||||||
|
);
|
||||||
|
assert_eq!(version_compare("0.2.4", "0.3.0"), std::cmp::Ordering::Less);
|
||||||
|
assert_eq!(version_compare("0.3.0", "0.3.0"), std::cmp::Ordering::Equal);
|
||||||
|
assert_eq!(
|
||||||
|
version_compare("1.0.0", "0.9.9"),
|
||||||
|
std::cmp::Ordering::Greater
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
version_compare("0.10.0", "0.9.0"),
|
||||||
|
std::cmp::Ordering::Greater
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
-56
@@ -41,7 +41,6 @@ impl SessionConfig {
|
|||||||
pub struct TmuxSession {
|
pub struct TmuxSession {
|
||||||
pub(crate) session_name: String,
|
pub(crate) session_name: String,
|
||||||
project_path: String,
|
project_path: String,
|
||||||
base_index: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TmuxSession {
|
impl TmuxSession {
|
||||||
@@ -53,34 +52,12 @@ impl TmuxSession {
|
|||||||
.replace('.', "_")
|
.replace('.', "_")
|
||||||
.replace(' ', "-");
|
.replace(' ', "-");
|
||||||
|
|
||||||
let base_index = Self::get_base_index();
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
session_name,
|
session_name,
|
||||||
project_path: project_path.display().to_string(),
|
project_path: project_path.display().to_string(),
|
||||||
base_index,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_base_index() -> usize {
|
|
||||||
// Try to get base-index from tmux
|
|
||||||
let output = Command::new("tmux")
|
|
||||||
.args(["show-options", "-gv", "base-index"])
|
|
||||||
.output();
|
|
||||||
|
|
||||||
if let Ok(output) = output
|
|
||||||
&& output.status.success()
|
|
||||||
{
|
|
||||||
let index_str = String::from_utf8_lossy(&output.stdout);
|
|
||||||
if let Ok(index) = index_str.trim().parse::<usize>() {
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to 0 if we can't determine
|
|
||||||
0
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create(&self, config: &SessionConfig) -> Result<()> {
|
pub fn create(&self, config: &SessionConfig) -> Result<()> {
|
||||||
// Check if we're already inside a tmux session
|
// Check if we're already inside a tmux session
|
||||||
let inside_tmux = std::env::var("TMUX").is_ok();
|
let inside_tmux = std::env::var("TMUX").is_ok();
|
||||||
@@ -167,25 +144,23 @@ impl TmuxSession {
|
|||||||
.status()
|
.status()
|
||||||
.context("Failed to create tmux session")?;
|
.context("Failed to create tmux session")?;
|
||||||
|
|
||||||
// Create panes for first window if specified
|
let first_target = format!("{}:{}", self.session_name, first_window.name);
|
||||||
|
|
||||||
if !first_window.panes.is_empty() {
|
if !first_window.panes.is_empty() {
|
||||||
self.create_panes(self.base_index, &first_window.panes)?;
|
self.create_panes(&first_target, &first_window.panes)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply layout for first window if specified
|
|
||||||
if let Some(layout) = &first_window.layout {
|
if let Some(layout) = &first_window.layout {
|
||||||
self.apply_layout(self.base_index, layout)?;
|
self.apply_layout(&first_target, layout)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create additional windows
|
// Create additional windows, targeting by session name so tmux auto-assigns the index
|
||||||
for (index, window) in config.windows.iter().skip(1).enumerate() {
|
for window in config.windows.iter().skip(1) {
|
||||||
let window_index = self.base_index + index + 1;
|
|
||||||
|
|
||||||
Command::new("tmux")
|
Command::new("tmux")
|
||||||
.args([
|
.args([
|
||||||
"new-window",
|
"new-window",
|
||||||
"-t",
|
"-t",
|
||||||
&format!("{}:{}", self.session_name, window_index),
|
&self.session_name,
|
||||||
"-n",
|
"-n",
|
||||||
&window.name,
|
&window.name,
|
||||||
"-c",
|
"-c",
|
||||||
@@ -194,46 +169,44 @@ impl TmuxSession {
|
|||||||
.status()
|
.status()
|
||||||
.with_context(|| format!("Failed to create window: {}", window.name))?;
|
.with_context(|| format!("Failed to create window: {}", window.name))?;
|
||||||
|
|
||||||
// Create panes if specified
|
let target = format!("{}:{}", self.session_name, window.name);
|
||||||
|
|
||||||
if !window.panes.is_empty() {
|
if !window.panes.is_empty() {
|
||||||
self.create_panes(window_index, &window.panes)?;
|
self.create_panes(&target, &window.panes)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply layout if specified
|
|
||||||
if let Some(layout) = &window.layout {
|
if let Some(layout) = &window.layout {
|
||||||
self.apply_layout(window_index, layout)?;
|
self.apply_layout(&target, layout)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select the first window
|
// Select the first window by name
|
||||||
Command::new("tmux")
|
Command::new("tmux")
|
||||||
.args([
|
.args(["select-window", "-t", &first_target])
|
||||||
"select-window",
|
|
||||||
"-t",
|
|
||||||
&format!("{}:{}", self.session_name, self.base_index),
|
|
||||||
])
|
|
||||||
.status()
|
.status()
|
||||||
.context("Failed to select first window")?;
|
.context("Failed to select first window")?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_panes(&self, window_index: usize, panes: &[String]) -> Result<()> {
|
fn create_panes(&self, window_target: &str, panes: &[String]) -> Result<()> {
|
||||||
for (pane_index, command) in panes.iter().enumerate() {
|
for (pane_index, command) in panes.iter().enumerate() {
|
||||||
let target = format!("{}:{}", self.session_name, window_index);
|
|
||||||
|
|
||||||
// First pane already exists (created with the window), skip split
|
// First pane already exists (created with the window), skip split
|
||||||
if pane_index > 0 {
|
if pane_index > 0 {
|
||||||
// Create new pane by splitting
|
|
||||||
Command::new("tmux")
|
Command::new("tmux")
|
||||||
.args(["split-window", "-t", &target, "-c", &self.project_path])
|
.args([
|
||||||
|
"split-window",
|
||||||
|
"-t",
|
||||||
|
window_target,
|
||||||
|
"-c",
|
||||||
|
&self.project_path,
|
||||||
|
])
|
||||||
.status()
|
.status()
|
||||||
.context("Failed to split pane")?;
|
.context("Failed to split pane")?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the command to the pane if it's not empty
|
|
||||||
if !command.is_empty() {
|
if !command.is_empty() {
|
||||||
let pane_target = format!("{}:{}.{}", self.session_name, window_index, pane_index);
|
let pane_target = format!("{}.{}", window_target, pane_index);
|
||||||
Command::new("tmux")
|
Command::new("tmux")
|
||||||
.args(["send-keys", "-t", &pane_target, command, "Enter"])
|
.args(["send-keys", "-t", &pane_target, command, "Enter"])
|
||||||
.status()
|
.status()
|
||||||
@@ -244,14 +217,9 @@ impl TmuxSession {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_layout(&self, window_index: usize, layout: &str) -> Result<()> {
|
fn apply_layout(&self, window_target: &str, layout: &str) -> Result<()> {
|
||||||
Command::new("tmux")
|
Command::new("tmux")
|
||||||
.args([
|
.args(["select-layout", "-t", window_target, layout])
|
||||||
"select-layout",
|
|
||||||
"-t",
|
|
||||||
&format!("{}:{}", self.session_name, window_index),
|
|
||||||
layout,
|
|
||||||
])
|
|
||||||
.status()
|
.status()
|
||||||
.with_context(|| format!("Failed to apply layout: {}", layout))?;
|
.with_context(|| format!("Failed to apply layout: {}", layout))?;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,543 @@
|
|||||||
|
use crate::session::Window;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use lipgloss::{Color, Style};
|
||||||
|
use std::io::{self, Write};
|
||||||
|
|
||||||
|
// Tokyo Night theme colors (as RGB tuples)
|
||||||
|
fn color_blue() -> Color {
|
||||||
|
Color::from_rgb(122, 162, 247)
|
||||||
|
} // #7AA2F7
|
||||||
|
fn color_purple() -> Color {
|
||||||
|
Color::from_rgb(187, 154, 247)
|
||||||
|
} // #BB9AF7
|
||||||
|
fn color_light_gray() -> Color {
|
||||||
|
Color::from_rgb(169, 177, 214)
|
||||||
|
} // #A9B1D6
|
||||||
|
fn color_dark_gray() -> Color {
|
||||||
|
Color::from_rgb(86, 95, 137)
|
||||||
|
} // #565F89
|
||||||
|
fn color_green() -> Color {
|
||||||
|
Color::from_rgb(158, 206, 106)
|
||||||
|
} // #9ECE6A
|
||||||
|
fn color_orange() -> Color {
|
||||||
|
Color::from_rgb(224, 175, 104)
|
||||||
|
} // #E0AF68
|
||||||
|
|
||||||
|
/// Renders a styled welcome screen for first-time setup
|
||||||
|
pub fn render_welcome_banner() {
|
||||||
|
let title_style = Style::new().bold(true).foreground(color_blue());
|
||||||
|
|
||||||
|
let subtitle_style = Style::new().foreground(color_purple());
|
||||||
|
|
||||||
|
let text_style = Style::new().foreground(color_light_gray());
|
||||||
|
|
||||||
|
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("{}", title_style.render(" 🚀 Welcome to tmuxido!"));
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
subtitle_style.render(" 📁 Let's set up your project directories")
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
text_style.render(" Please specify where tmuxido should look for your projects.")
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
text_style.render(" You can add multiple paths separated by commas:")
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
hint_style.render(" 💡 Example: ~/Projects, ~/work, ~/personal/repos")
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a prompt asking for paths
|
||||||
|
pub fn render_paths_prompt() -> Result<String> {
|
||||||
|
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||||||
|
|
||||||
|
print!(" {} ", prompt_style.render("❯ Paths:"));
|
||||||
|
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 success message after config is created with all settings
|
||||||
|
pub fn render_config_created(
|
||||||
|
paths: &[String],
|
||||||
|
max_depth: usize,
|
||||||
|
cache_enabled: bool,
|
||||||
|
cache_ttl_hours: u64,
|
||||||
|
windows: &[Window],
|
||||||
|
) {
|
||||||
|
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 path_style = Style::new().foreground(color_blue());
|
||||||
|
let window_style = Style::new().foreground(color_purple());
|
||||||
|
let info_style = Style::new().foreground(color_dark_gray());
|
||||||
|
let bool_enabled_style = Style::new().bold(true).foreground(color_green());
|
||||||
|
let bool_disabled_style = Style::new().bold(true).foreground(color_orange());
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("{}", success_style.render(" ✅ Configuration saved!"));
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Project discovery section
|
||||||
|
println!("{}", label_style.render(" 📁 Project Discovery:"));
|
||||||
|
println!(
|
||||||
|
" {} {} {}",
|
||||||
|
label_style.render("Max scan depth:"),
|
||||||
|
value_style.render(&max_depth.to_string()),
|
||||||
|
label_style.render("levels")
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Paths
|
||||||
|
println!("{}", label_style.render(" 📂 Directories:"));
|
||||||
|
for path in paths {
|
||||||
|
println!(" {}", path_style.render(&format!("• {}", path)));
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Cache settings
|
||||||
|
println!("{}", label_style.render(" 💾 Cache Settings:"));
|
||||||
|
let cache_status = if cache_enabled {
|
||||||
|
bool_enabled_style.render("enabled")
|
||||||
|
} else {
|
||||||
|
bool_disabled_style.render("disabled")
|
||||||
|
};
|
||||||
|
println!(" {} {}", label_style.render("Status:"), cache_status);
|
||||||
|
if cache_enabled {
|
||||||
|
println!(
|
||||||
|
" {} {} {}",
|
||||||
|
label_style.render("TTL:"),
|
||||||
|
value_style.render(&cache_ttl_hours.to_string()),
|
||||||
|
label_style.render("hours")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Default session
|
||||||
|
println!("{}", label_style.render(" 🪟 Default Windows:"));
|
||||||
|
for window in windows {
|
||||||
|
println!(" {}", window_style.render(&format!("◦ {}", window.name)));
|
||||||
|
if !window.panes.is_empty() {
|
||||||
|
for (i, pane) in window.panes.iter().enumerate() {
|
||||||
|
let pane_display = if pane.is_empty() {
|
||||||
|
format!(" └─ pane {} (shell)", i + 1)
|
||||||
|
} else {
|
||||||
|
format!(" └─ pane {}: {}", i + 1, pane)
|
||||||
|
};
|
||||||
|
println!("{}", info_style.render(&pane_display));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
info_style.render(
|
||||||
|
" ⚙️ You can edit ~/.config/tmuxido/tmuxido.toml anytime to change these settings."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a warning when user provides no input (fallback to default)
|
||||||
|
pub fn render_fallback_message() {
|
||||||
|
let warning_style = Style::new().italic(true).foreground(color_orange());
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
warning_style.render(" ⚠️ No paths provided. Using default: ~/Projects")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a section header for grouping related settings
|
||||||
|
pub fn render_section_header(title: &str) {
|
||||||
|
let header_style = Style::new().bold(true).foreground(color_purple());
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("{}", header_style.render(&format!(" 📋 {}", title)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a prompt for max_depth with instructions
|
||||||
|
pub fn render_max_depth_prompt() -> 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(" How many levels deep should tmuxido search for git repositories?")
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
hint_style.render(" Higher values = deeper search, but slower. Default: 5")
|
||||||
|
);
|
||||||
|
print!(" {} ", prompt_style.render("❯ Max depth:"));
|
||||||
|
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 prompt for cache_enabled with instructions
|
||||||
|
pub fn render_cache_enabled_prompt() -> 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(" Enable caching to speed up project discovery?")
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
hint_style.render(" Cache avoids rescanning unchanged directories. Default: yes (y)")
|
||||||
|
);
|
||||||
|
print!(" {} ", prompt_style.render("❯ Enable cache? (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")?;
|
||||||
|
|
||||||
|
Ok(input.trim().to_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a prompt for cache_ttl_hours with instructions
|
||||||
|
pub fn render_cache_ttl_prompt() -> 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(" How long should the cache remain valid (in hours)?")
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
hint_style.render(" After this time, tmuxido will rescan your directories. Default: 24")
|
||||||
|
);
|
||||||
|
print!(" {} ", prompt_style.render("❯ Cache TTL (hours):"));
|
||||||
|
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 prompt for default session windows with instructions
|
||||||
|
pub fn render_windows_prompt() -> 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(" What windows should be created by default in new tmux sessions?")
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
hint_style.render(" Enter window names separated by commas. Default: editor, terminal")
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
hint_style.render(" 💡 Tip: Common choices are 'editor', 'terminal', 'server', 'logs'")
|
||||||
|
);
|
||||||
|
print!(" {} ", prompt_style.render("❯ Window names:"));
|
||||||
|
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 prompt asking for panes in a specific window
|
||||||
|
pub fn render_panes_prompt(window_name: &str) -> Result<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());
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!(" Configuring window: {}", window_style.render(window_name));
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
hint_style
|
||||||
|
.render(" Enter pane names separated by commas, or leave empty for a single pane.")
|
||||||
|
);
|
||||||
|
println!("{}", hint_style.render(" 💡 Example: code, logs, tests"));
|
||||||
|
print!(" {} ", prompt_style.render("❯ Pane names:"));
|
||||||
|
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 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());
|
||||||
|
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||||||
|
let pane_style = Style::new().foreground(color_blue());
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
hint_style.render(&format!(
|
||||||
|
" What command should run in pane '{}' on startup?",
|
||||||
|
pane_style.render(pane_name)
|
||||||
|
))
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
hint_style.render(" Leave empty to run the default shell, or enter a command like 'nvim', 'npm run dev'")
|
||||||
|
);
|
||||||
|
print!(" {} ", prompt_style.render("❯ Command:"));
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse max_depth input, returning None for empty/invalid (use default)
|
||||||
|
pub fn parse_max_depth_input(input: &str) -> Option<usize> {
|
||||||
|
let trimmed = input.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
trimmed.parse::<usize>().ok().filter(|&n| n > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse cache enabled input, returning None for empty (use default)
|
||||||
|
pub fn parse_cache_enabled_input(input: &str) -> Option<bool> {
|
||||||
|
let trimmed = input.trim().to_lowercase();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match trimmed.as_str() {
|
||||||
|
"y" | "yes" => Some(true),
|
||||||
|
"n" | "no" => Some(false),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse cache TTL input, returning None for empty/invalid (use default)
|
||||||
|
pub fn parse_cache_ttl_input(input: &str) -> Option<u64> {
|
||||||
|
let trimmed = input.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
trimmed.parse::<u64>().ok().filter(|&n| n > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse comma-separated list into Vec<String>, filtering empty items
|
||||||
|
pub fn parse_comma_separated_list(input: &str) -> Vec<String> {
|
||||||
|
input
|
||||||
|
.trim()
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_none_for_empty_max_depth() {
|
||||||
|
assert_eq!(parse_max_depth_input(""), None);
|
||||||
|
assert_eq!(parse_max_depth_input(" "), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_parse_valid_max_depth() {
|
||||||
|
assert_eq!(parse_max_depth_input("5"), Some(5));
|
||||||
|
assert_eq!(parse_max_depth_input("10"), Some(10));
|
||||||
|
assert_eq!(parse_max_depth_input(" 3 "), Some(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_none_for_invalid_max_depth() {
|
||||||
|
assert_eq!(parse_max_depth_input("0"), None);
|
||||||
|
assert_eq!(parse_max_depth_input("-1"), None);
|
||||||
|
assert_eq!(parse_max_depth_input("abc"), None);
|
||||||
|
assert_eq!(parse_max_depth_input("3.5"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_none_for_empty_cache_enabled() {
|
||||||
|
assert_eq!(parse_cache_enabled_input(""), None);
|
||||||
|
assert_eq!(parse_cache_enabled_input(" "), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_parse_yes_as_true() {
|
||||||
|
assert_eq!(parse_cache_enabled_input("y"), Some(true));
|
||||||
|
assert_eq!(parse_cache_enabled_input("Y"), Some(true));
|
||||||
|
assert_eq!(parse_cache_enabled_input("yes"), Some(true));
|
||||||
|
assert_eq!(parse_cache_enabled_input("YES"), Some(true));
|
||||||
|
assert_eq!(parse_cache_enabled_input("Yes"), Some(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_parse_no_as_false() {
|
||||||
|
assert_eq!(parse_cache_enabled_input("n"), Some(false));
|
||||||
|
assert_eq!(parse_cache_enabled_input("N"), Some(false));
|
||||||
|
assert_eq!(parse_cache_enabled_input("no"), Some(false));
|
||||||
|
assert_eq!(parse_cache_enabled_input("NO"), Some(false));
|
||||||
|
assert_eq!(parse_cache_enabled_input("No"), Some(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_none_for_invalid_cache_input() {
|
||||||
|
assert_eq!(parse_cache_enabled_input("maybe"), None);
|
||||||
|
assert_eq!(parse_cache_enabled_input("true"), None);
|
||||||
|
assert_eq!(parse_cache_enabled_input("1"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_none_for_empty_cache_ttl() {
|
||||||
|
assert_eq!(parse_cache_ttl_input(""), None);
|
||||||
|
assert_eq!(parse_cache_ttl_input(" "), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_parse_valid_cache_ttl() {
|
||||||
|
assert_eq!(parse_cache_ttl_input("24"), Some(24));
|
||||||
|
assert_eq!(parse_cache_ttl_input("12"), Some(12));
|
||||||
|
assert_eq!(parse_cache_ttl_input(" 48 "), Some(48));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_none_for_invalid_cache_ttl() {
|
||||||
|
assert_eq!(parse_cache_ttl_input("0"), None);
|
||||||
|
assert_eq!(parse_cache_ttl_input("-1"), None);
|
||||||
|
assert_eq!(parse_cache_ttl_input("abc"), None);
|
||||||
|
assert_eq!(parse_cache_ttl_input("12.5"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_parse_empty_comma_list() {
|
||||||
|
let result = parse_comma_separated_list("");
|
||||||
|
assert!(result.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_parse_single_item() {
|
||||||
|
let result = parse_comma_separated_list("editor");
|
||||||
|
assert_eq!(result, vec!["editor"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_parse_multiple_items() {
|
||||||
|
let result = parse_comma_separated_list("editor, terminal, server");
|
||||||
|
assert_eq!(result, vec!["editor", "terminal", "server"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_trim_whitespace_in_comma_list() {
|
||||||
|
let result = parse_comma_separated_list(" editor , terminal ");
|
||||||
|
assert_eq!(result, vec!["editor", "terminal"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_filter_empty_parts_in_comma_list() {
|
||||||
|
let result = parse_comma_separated_list("editor,,terminal");
|
||||||
|
assert_eq!(result, vec!["editor", "terminal"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn color_blue_should_return_expected_rgb() {
|
||||||
|
let color = color_blue();
|
||||||
|
// We can't easily test the internal RGB values, but we can verify it doesn't panic
|
||||||
|
let _ = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn color_functions_should_return_distinct_colors() {
|
||||||
|
// Verify all color functions return valid Color objects
|
||||||
|
let colors = vec![
|
||||||
|
color_blue(),
|
||||||
|
color_purple(),
|
||||||
|
color_light_gray(),
|
||||||
|
color_dark_gray(),
|
||||||
|
color_green(),
|
||||||
|
color_orange(),
|
||||||
|
];
|
||||||
|
// Just verify they don't panic and are distinct
|
||||||
|
assert_eq!(colors.len(), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_section_header_should_not_panic() {
|
||||||
|
// This test verifies the function doesn't panic
|
||||||
|
// We can't capture stdout easily in unit tests without additional setup
|
||||||
|
render_section_header("Test Section");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_welcome_banner_should_not_panic() {
|
||||||
|
render_welcome_banner();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_fallback_message_should_not_panic() {
|
||||||
|
render_fallback_message();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_config_created_should_not_panic() {
|
||||||
|
let windows = vec![
|
||||||
|
Window {
|
||||||
|
name: "editor".to_string(),
|
||||||
|
panes: vec!["nvim .".to_string()],
|
||||||
|
layout: None,
|
||||||
|
},
|
||||||
|
Window {
|
||||||
|
name: "terminal".to_string(),
|
||||||
|
panes: vec![],
|
||||||
|
layout: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
render_config_created(&vec!["~/Projects".to_string()], 5, true, 24, &windows);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_config_created_with_disabled_cache_should_not_panic() {
|
||||||
|
let windows = vec![Window {
|
||||||
|
name: "editor".to_string(),
|
||||||
|
panes: vec![],
|
||||||
|
layout: None,
|
||||||
|
}];
|
||||||
|
render_config_created(&vec!["~/work".to_string()], 3, false, 24, &windows);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::self_update;
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
struct UpdateCheckCache {
|
||||||
|
last_checked: u64,
|
||||||
|
latest_version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_and_notify(config: &Config) {
|
||||||
|
let cache = load_cache();
|
||||||
|
check_and_notify_internal(
|
||||||
|
config.update_check_interval_hours,
|
||||||
|
cache,
|
||||||
|
&|| self_update::fetch_latest_tag(),
|
||||||
|
&save_cache,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_and_notify_internal(
|
||||||
|
interval_hours: u64,
|
||||||
|
mut cache: UpdateCheckCache,
|
||||||
|
fetcher: &dyn Fn() -> Result<String>,
|
||||||
|
saver: &dyn Fn(&UpdateCheckCache),
|
||||||
|
) -> bool {
|
||||||
|
if interval_hours == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let elapsed = elapsed_hours(cache.last_checked);
|
||||||
|
|
||||||
|
if elapsed >= interval_hours
|
||||||
|
&& let Ok(latest) = fetcher()
|
||||||
|
{
|
||||||
|
let latest_clean = latest.trim_start_matches('v').to_string();
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
cache.last_checked = now;
|
||||||
|
cache.latest_version = latest_clean;
|
||||||
|
saver(&cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = self_update::current_version();
|
||||||
|
let latest_clean = cache.latest_version.trim_start_matches('v');
|
||||||
|
if !latest_clean.is_empty()
|
||||||
|
&& self_update::version_compare(latest_clean, current) == std::cmp::Ordering::Greater
|
||||||
|
{
|
||||||
|
print_update_notice(current, latest_clean);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_update_notice(current: &str, latest: &str) {
|
||||||
|
let msg1 = format!(" Update available: {} \u{2192} {} ", current, latest);
|
||||||
|
let msg2 = " Run tmuxido --update to install. ";
|
||||||
|
let w1 = msg1.chars().count();
|
||||||
|
let w2 = msg2.chars().count();
|
||||||
|
let width = w1.max(w2);
|
||||||
|
let border = "\u{2500}".repeat(width);
|
||||||
|
println!("\u{250c}{}\u{2510}", border);
|
||||||
|
println!("\u{2502}{}\u{2502}", pad_to_chars(&msg1, width));
|
||||||
|
println!("\u{2502}{}\u{2502}", pad_to_chars(msg2, width));
|
||||||
|
println!("\u{2514}{}\u{2518}", border);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pad_to_chars(s: &str, width: usize) -> String {
|
||||||
|
let char_count = s.chars().count();
|
||||||
|
if char_count >= width {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}{}", s, " ".repeat(width - char_count))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_path() -> Result<PathBuf> {
|
||||||
|
let cache_dir = dirs::cache_dir()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Could not determine cache directory"))?
|
||||||
|
.join("tmuxido");
|
||||||
|
Ok(cache_dir.join("update_check.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_cache() -> UpdateCheckCache {
|
||||||
|
cache_path()
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_cache(cache: &UpdateCheckCache) {
|
||||||
|
if let Ok(path) = cache_path() {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
if let Ok(json) = serde_json::to_string(cache) {
|
||||||
|
let _ = std::fs::write(path, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn elapsed_hours(ts: u64) -> u64 {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
now.saturating_sub(ts) / 3600
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
fn make_cache(last_checked: u64, latest_version: &str) -> UpdateCheckCache {
|
||||||
|
UpdateCheckCache {
|
||||||
|
last_checked,
|
||||||
|
latest_version: latest_version.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_ts() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_not_notify_when_interval_is_zero() {
|
||||||
|
let cache = make_cache(0, "99.0.0");
|
||||||
|
let fetcher_called = RefCell::new(false);
|
||||||
|
|
||||||
|
let result = check_and_notify_internal(
|
||||||
|
0,
|
||||||
|
cache,
|
||||||
|
&|| {
|
||||||
|
*fetcher_called.borrow_mut() = true;
|
||||||
|
Ok("99.0.0".to_string())
|
||||||
|
},
|
||||||
|
&|_| {},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!result);
|
||||||
|
assert!(!fetcher_called.into_inner());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_not_check_when_interval_not_elapsed() {
|
||||||
|
let cache = make_cache(now_ts(), "");
|
||||||
|
let fetcher_called = RefCell::new(false);
|
||||||
|
|
||||||
|
check_and_notify_internal(
|
||||||
|
24,
|
||||||
|
cache,
|
||||||
|
&|| {
|
||||||
|
*fetcher_called.borrow_mut() = true;
|
||||||
|
Ok("99.0.0".to_string())
|
||||||
|
},
|
||||||
|
&|_| {},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!fetcher_called.into_inner());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_check_when_interval_elapsed() {
|
||||||
|
let cache = make_cache(0, "");
|
||||||
|
let fetcher_called = RefCell::new(false);
|
||||||
|
|
||||||
|
check_and_notify_internal(
|
||||||
|
1,
|
||||||
|
cache,
|
||||||
|
&|| {
|
||||||
|
*fetcher_called.borrow_mut() = true;
|
||||||
|
Ok(self_update::current_version().to_string())
|
||||||
|
},
|
||||||
|
&|_| {},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(fetcher_called.into_inner());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_not_notify_when_versions_equal() {
|
||||||
|
let current = self_update::current_version();
|
||||||
|
let cache = make_cache(now_ts(), current);
|
||||||
|
|
||||||
|
let result = check_and_notify_internal(24, cache, &|| unreachable!(), &|_| {});
|
||||||
|
|
||||||
|
assert!(!result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_detect_update_available() {
|
||||||
|
let cache = make_cache(now_ts(), "99.0.0");
|
||||||
|
|
||||||
|
let result = check_and_notify_internal(24, cache, &|| unreachable!(), &|_| {});
|
||||||
|
|
||||||
|
assert!(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_not_detect_update_when_current_is_newer() {
|
||||||
|
let cache = make_cache(now_ts(), "0.0.1");
|
||||||
|
|
||||||
|
let result = check_and_notify_internal(24, cache, &|| unreachable!(), &|_| {});
|
||||||
|
|
||||||
|
assert!(!result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ fn make_config(max_depth: usize) -> Config {
|
|||||||
max_depth,
|
max_depth,
|
||||||
cache_enabled: true,
|
cache_enabled: true,
|
||||||
cache_ttl_hours: 24,
|
cache_ttl_hours: 24,
|
||||||
|
update_check_interval_hours: 24,
|
||||||
default_session: SessionConfig { windows: vec![] },
|
default_session: SessionConfig { windows: vec![] },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user