Compare commits
3 Commits
ae5ef47877
...
4caca0b26b
| Author | SHA1 | Date | |
|---|---|---|---|
| 4caca0b26b | |||
| 3644f02b21 | |||
| d1b1b4ef49 |
11
CHANGELOG.md
11
CHANGELOG.md
@ -4,6 +4,17 @@ 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.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
|
## [0.5.2] - 2026-03-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -864,7 +864,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tmuxido"
|
name = "tmuxido"
|
||||||
version = "0.5.2"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tmuxido"
|
name = "tmuxido"
|
||||||
version = "0.5.2"
|
version = "0.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@ -15,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,
|
||||||
}
|
}
|
||||||
@ -31,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;
|
||||||
|
|
||||||
@ -109,6 +115,7 @@ impl Config {
|
|||||||
max_depth,
|
max_depth,
|
||||||
cache_enabled,
|
cache_enabled,
|
||||||
cache_ttl_hours,
|
cache_ttl_hours,
|
||||||
|
update_check_interval_hours: default_update_check_interval_hours(),
|
||||||
default_session: SessionConfig { windows },
|
default_session: SessionConfig { windows },
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -229,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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -245,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]
|
||||||
|
|||||||
@ -4,6 +4,7 @@ pub mod deps;
|
|||||||
pub mod self_update;
|
pub mod self_update;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
pub mod update_check;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use cache::ProjectCache;
|
use cache::ProjectCache;
|
||||||
@ -192,6 +193,7 @@ mod tests {
|
|||||||
max_depth: 3,
|
max_depth: 3,
|
||||||
cache_enabled,
|
cache_enabled,
|
||||||
cache_ttl_hours: 24,
|
cache_ttl_hours: 24,
|
||||||
|
update_check_interval_hours: 24,
|
||||||
default_session: session::SessionConfig { windows: vec![] },
|
default_session: session::SessionConfig { windows: vec![] },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ use std::process::{Command, Stdio};
|
|||||||
use tmuxido::config::Config;
|
use tmuxido::config::Config;
|
||||||
use tmuxido::deps::ensure_dependencies;
|
use tmuxido::deps::ensure_dependencies;
|
||||||
use tmuxido::self_update;
|
use tmuxido::self_update;
|
||||||
|
use tmuxido::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)]
|
||||||
@ -48,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)?;
|
||||||
|
|||||||
@ -27,7 +27,7 @@ fn detect_arch() -> Result<&'static str> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch latest release tag from Gitea API
|
/// Fetch latest release tag from Gitea API
|
||||||
fn fetch_latest_tag() -> Result<String> {
|
pub(crate) fn fetch_latest_tag() -> Result<String> {
|
||||||
let url = format!("{}/api/v1/repos/{}/releases?limit=1&page=1", BASE_URL, REPO);
|
let url = format!("{}/api/v1/repos/{}/releases?limit=1&page=1", BASE_URL, REPO);
|
||||||
|
|
||||||
let output = Command::new("curl")
|
let output = Command::new("curl")
|
||||||
@ -166,7 +166,7 @@ pub fn self_update() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Compare two semver versions
|
/// Compare two semver versions
|
||||||
fn version_compare(a: &str, b: &str) -> std::cmp::Ordering {
|
pub(crate) fn version_compare(a: &str, b: &str) -> std::cmp::Ordering {
|
||||||
let parse = |s: &str| {
|
let parse = |s: &str| {
|
||||||
s.split('.')
|
s.split('.')
|
||||||
.filter_map(|n| n.parse::<u32>().ok())
|
.filter_map(|n| n.parse::<u32>().ok())
|
||||||
|
|||||||
219
src/update_check.rs
Normal file
219
src/update_check.rs
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::self_update;
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
struct UpdateCheckCache {
|
||||||
|
last_checked: u64,
|
||||||
|
latest_version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_and_notify(config: &Config) {
|
||||||
|
let cache = load_cache();
|
||||||
|
check_and_notify_internal(
|
||||||
|
config.update_check_interval_hours,
|
||||||
|
cache,
|
||||||
|
&|| self_update::fetch_latest_tag(),
|
||||||
|
&save_cache,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_and_notify_internal(
|
||||||
|
interval_hours: u64,
|
||||||
|
mut cache: UpdateCheckCache,
|
||||||
|
fetcher: &dyn Fn() -> Result<String>,
|
||||||
|
saver: &dyn Fn(&UpdateCheckCache),
|
||||||
|
) -> bool {
|
||||||
|
if interval_hours == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let elapsed = elapsed_hours(cache.last_checked);
|
||||||
|
|
||||||
|
if elapsed >= interval_hours
|
||||||
|
&& let Ok(latest) = fetcher()
|
||||||
|
{
|
||||||
|
let latest_clean = latest.trim_start_matches('v').to_string();
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
cache.last_checked = now;
|
||||||
|
cache.latest_version = latest_clean;
|
||||||
|
saver(&cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = self_update::current_version();
|
||||||
|
let latest_clean = cache.latest_version.trim_start_matches('v');
|
||||||
|
if !latest_clean.is_empty()
|
||||||
|
&& self_update::version_compare(latest_clean, current) == std::cmp::Ordering::Greater
|
||||||
|
{
|
||||||
|
print_update_notice(current, latest_clean);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_update_notice(current: &str, latest: &str) {
|
||||||
|
let msg1 = format!(" Update available: {} \u{2192} {} ", current, latest);
|
||||||
|
let msg2 = " Run tmuxido --update to install. ";
|
||||||
|
let w1 = msg1.chars().count();
|
||||||
|
let w2 = msg2.chars().count();
|
||||||
|
let width = w1.max(w2);
|
||||||
|
let border = "\u{2500}".repeat(width);
|
||||||
|
println!("\u{250c}{}\u{2510}", border);
|
||||||
|
println!("\u{2502}{}\u{2502}", pad_to_chars(&msg1, width));
|
||||||
|
println!("\u{2502}{}\u{2502}", pad_to_chars(msg2, width));
|
||||||
|
println!("\u{2514}{}\u{2518}", border);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pad_to_chars(s: &str, width: usize) -> String {
|
||||||
|
let char_count = s.chars().count();
|
||||||
|
if char_count >= width {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}{}", s, " ".repeat(width - char_count))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_path() -> Result<PathBuf> {
|
||||||
|
let cache_dir = dirs::cache_dir()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Could not determine cache directory"))?
|
||||||
|
.join("tmuxido");
|
||||||
|
Ok(cache_dir.join("update_check.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_cache() -> UpdateCheckCache {
|
||||||
|
cache_path()
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_cache(cache: &UpdateCheckCache) {
|
||||||
|
if let Ok(path) = cache_path() {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
if let Ok(json) = serde_json::to_string(cache) {
|
||||||
|
let _ = std::fs::write(path, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn elapsed_hours(ts: u64) -> u64 {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
now.saturating_sub(ts) / 3600
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
fn make_cache(last_checked: u64, latest_version: &str) -> UpdateCheckCache {
|
||||||
|
UpdateCheckCache {
|
||||||
|
last_checked,
|
||||||
|
latest_version: latest_version.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_ts() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_not_notify_when_interval_is_zero() {
|
||||||
|
let cache = make_cache(0, "99.0.0");
|
||||||
|
let fetcher_called = RefCell::new(false);
|
||||||
|
|
||||||
|
let result = check_and_notify_internal(
|
||||||
|
0,
|
||||||
|
cache,
|
||||||
|
&|| {
|
||||||
|
*fetcher_called.borrow_mut() = true;
|
||||||
|
Ok("99.0.0".to_string())
|
||||||
|
},
|
||||||
|
&|_| {},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!result);
|
||||||
|
assert!(!fetcher_called.into_inner());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_not_check_when_interval_not_elapsed() {
|
||||||
|
let cache = make_cache(now_ts(), "");
|
||||||
|
let fetcher_called = RefCell::new(false);
|
||||||
|
|
||||||
|
check_and_notify_internal(
|
||||||
|
24,
|
||||||
|
cache,
|
||||||
|
&|| {
|
||||||
|
*fetcher_called.borrow_mut() = true;
|
||||||
|
Ok("99.0.0".to_string())
|
||||||
|
},
|
||||||
|
&|_| {},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!fetcher_called.into_inner());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_check_when_interval_elapsed() {
|
||||||
|
let cache = make_cache(0, "");
|
||||||
|
let fetcher_called = RefCell::new(false);
|
||||||
|
|
||||||
|
check_and_notify_internal(
|
||||||
|
1,
|
||||||
|
cache,
|
||||||
|
&|| {
|
||||||
|
*fetcher_called.borrow_mut() = true;
|
||||||
|
Ok(self_update::current_version().to_string())
|
||||||
|
},
|
||||||
|
&|_| {},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(fetcher_called.into_inner());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_not_notify_when_versions_equal() {
|
||||||
|
let current = self_update::current_version();
|
||||||
|
let cache = make_cache(now_ts(), current);
|
||||||
|
|
||||||
|
let result = check_and_notify_internal(24, cache, &|| unreachable!(), &|_| {});
|
||||||
|
|
||||||
|
assert!(!result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_detect_update_available() {
|
||||||
|
let cache = make_cache(now_ts(), "99.0.0");
|
||||||
|
|
||||||
|
let result = check_and_notify_internal(24, cache, &|| unreachable!(), &|_| {});
|
||||||
|
|
||||||
|
assert!(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_not_detect_update_when_current_is_newer() {
|
||||||
|
let cache = make_cache(now_ts(), "0.0.1");
|
||||||
|
|
||||||
|
let result = check_and_notify_internal(24, cache, &|| unreachable!(), &|_| {});
|
||||||
|
|
||||||
|
assert!(!result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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![] },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user