Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
973042ce7d | ||
|
|
0f27bedc94 | ||
|
|
da6311bc53 | ||
|
|
2b1773375a | ||
|
|
36aaa65945 | ||
|
|
10a38a1f85 | ||
|
|
42bdc1d409 | ||
|
|
a592c99375 |
+10
@@ -76,6 +76,16 @@ steps:
|
||||
commands:
|
||||
- apk add --no-cache curl jq
|
||||
- |
|
||||
# Delete existing release for this tag if present (handles retag scenarios)
|
||||
EXISTING_ID=$(curl -fsSL \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/releases/tags/$DRONE_TAG" \
|
||||
| jq -r '.id // empty')
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
curl -fsSL -X DELETE \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/releases/$EXISTING_ID"
|
||||
fi
|
||||
# Read DRONE_TAG via ENVIRON inside awk to avoid Drone's ${VAR} substitution
|
||||
# which would replace ${TAG} with an empty string before the shell runs.
|
||||
BODY=$(awk '
|
||||
|
||||
@@ -4,6 +4,32 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [0.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
|
||||
|
||||
Generated
+1
-1
@@ -864,7 +864,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tmuxido"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tmuxido"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -15,6 +15,8 @@ pub struct Config {
|
||||
pub cache_enabled: bool,
|
||||
#[serde(default = "default_cache_ttl_hours")]
|
||||
pub cache_ttl_hours: u64,
|
||||
#[serde(default = "default_update_check_interval_hours")]
|
||||
pub update_check_interval_hours: u64,
|
||||
#[serde(default = "default_session_config")]
|
||||
pub default_session: SessionConfig,
|
||||
}
|
||||
@@ -31,6 +33,10 @@ fn default_cache_ttl_hours() -> u64 {
|
||||
24
|
||||
}
|
||||
|
||||
fn default_update_check_interval_hours() -> u64 {
|
||||
24
|
||||
}
|
||||
|
||||
fn default_session_config() -> SessionConfig {
|
||||
use crate::session::Window;
|
||||
|
||||
@@ -109,6 +115,7 @@ impl Config {
|
||||
max_depth,
|
||||
cache_enabled,
|
||||
cache_ttl_hours,
|
||||
update_check_interval_hours: default_update_check_interval_hours(),
|
||||
default_session: SessionConfig { windows },
|
||||
};
|
||||
|
||||
@@ -229,6 +236,7 @@ impl Config {
|
||||
max_depth: 5,
|
||||
cache_enabled: true,
|
||||
cache_ttl_hours: 24,
|
||||
update_check_interval_hours: default_update_check_interval_hours(),
|
||||
default_session: default_session_config(),
|
||||
}
|
||||
}
|
||||
@@ -245,6 +253,7 @@ mod tests {
|
||||
assert_eq!(config.max_depth, 5);
|
||||
assert!(config.cache_enabled);
|
||||
assert_eq!(config.cache_ttl_hours, 24);
|
||||
assert_eq!(config.update_check_interval_hours, 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod deps;
|
||||
pub mod self_update;
|
||||
pub mod session;
|
||||
pub mod ui;
|
||||
pub mod update_check;
|
||||
|
||||
use anyhow::Result;
|
||||
use cache::ProjectCache;
|
||||
@@ -192,6 +193,7 @@ mod tests {
|
||||
max_depth: 3,
|
||||
cache_enabled,
|
||||
cache_ttl_hours: 24,
|
||||
update_check_interval_hours: 24,
|
||||
default_session: session::SessionConfig { windows: vec![] },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::process::{Command, Stdio};
|
||||
use tmuxido::config::Config;
|
||||
use tmuxido::deps::ensure_dependencies;
|
||||
use tmuxido::self_update;
|
||||
use tmuxido::update_check;
|
||||
use tmuxido::{get_projects, launch_tmux_session, show_cache_status};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -48,6 +49,9 @@ fn main() -> Result<()> {
|
||||
// Load config
|
||||
let config = Config::load()?;
|
||||
|
||||
// Periodic update check (silent on failure or no update)
|
||||
update_check::check_and_notify(&config);
|
||||
|
||||
// Handle cache status command
|
||||
if args.cache_status {
|
||||
show_cache_status(&config)?;
|
||||
|
||||
+17
-4
@@ -20,14 +20,14 @@ pub fn current_version() -> &'static str {
|
||||
fn detect_arch() -> Result<&'static str> {
|
||||
let arch = std::env::consts::ARCH;
|
||||
match arch {
|
||||
"x86_64" => Ok("x86_64-linux"),
|
||||
"aarch64" => Ok("aarch64-linux"),
|
||||
"x86_64" => Ok("tmuxido-x86_64-linux"),
|
||||
"aarch64" => Ok("tmuxido-aarch64-linux"),
|
||||
_ => Err(anyhow::anyhow!("Unsupported architecture: {}", arch)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 output = Command::new("curl")
|
||||
@@ -166,7 +166,7 @@ pub fn self_update() -> Result<()> {
|
||||
}
|
||||
|
||||
/// 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| {
|
||||
s.split('.')
|
||||
.filter_map(|n| n.parse::<u32>().ok())
|
||||
@@ -198,6 +198,19 @@ mod tests {
|
||||
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_compare_versions_correctly() {
|
||||
assert_eq!(
|
||||
|
||||
+24
-56
@@ -41,7 +41,6 @@ impl SessionConfig {
|
||||
pub struct TmuxSession {
|
||||
pub(crate) session_name: String,
|
||||
project_path: String,
|
||||
base_index: usize,
|
||||
}
|
||||
|
||||
impl TmuxSession {
|
||||
@@ -53,34 +52,12 @@ impl TmuxSession {
|
||||
.replace('.', "_")
|
||||
.replace(' ', "-");
|
||||
|
||||
let base_index = Self::get_base_index();
|
||||
|
||||
Self {
|
||||
session_name,
|
||||
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<()> {
|
||||
// Check if we're already inside a tmux session
|
||||
let inside_tmux = std::env::var("TMUX").is_ok();
|
||||
@@ -167,25 +144,23 @@ impl TmuxSession {
|
||||
.status()
|
||||
.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() {
|
||||
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 {
|
||||
self.apply_layout(self.base_index, layout)?;
|
||||
self.apply_layout(&first_target, layout)?;
|
||||
}
|
||||
|
||||
// Create additional windows
|
||||
for (index, window) in config.windows.iter().skip(1).enumerate() {
|
||||
let window_index = self.base_index + index + 1;
|
||||
|
||||
// Create additional windows, targeting by session name so tmux auto-assigns the index
|
||||
for window in config.windows.iter().skip(1) {
|
||||
Command::new("tmux")
|
||||
.args([
|
||||
"new-window",
|
||||
"-t",
|
||||
&format!("{}:{}", self.session_name, window_index),
|
||||
&self.session_name,
|
||||
"-n",
|
||||
&window.name,
|
||||
"-c",
|
||||
@@ -194,46 +169,44 @@ impl TmuxSession {
|
||||
.status()
|
||||
.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() {
|
||||
self.create_panes(window_index, &window.panes)?;
|
||||
self.create_panes(&target, &window.panes)?;
|
||||
}
|
||||
|
||||
// Apply layout if specified
|
||||
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")
|
||||
.args([
|
||||
"select-window",
|
||||
"-t",
|
||||
&format!("{}:{}", self.session_name, self.base_index),
|
||||
])
|
||||
.args(["select-window", "-t", &first_target])
|
||||
.status()
|
||||
.context("Failed to select first window")?;
|
||||
|
||||
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() {
|
||||
let target = format!("{}:{}", self.session_name, window_index);
|
||||
|
||||
// First pane already exists (created with the window), skip split
|
||||
if pane_index > 0 {
|
||||
// Create new pane by splitting
|
||||
Command::new("tmux")
|
||||
.args(["split-window", "-t", &target, "-c", &self.project_path])
|
||||
.args([
|
||||
"split-window",
|
||||
"-t",
|
||||
window_target,
|
||||
"-c",
|
||||
&self.project_path,
|
||||
])
|
||||
.status()
|
||||
.context("Failed to split pane")?;
|
||||
}
|
||||
|
||||
// Send the command to the pane if it's not 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")
|
||||
.args(["send-keys", "-t", &pane_target, command, "Enter"])
|
||||
.status()
|
||||
@@ -244,14 +217,9 @@ impl TmuxSession {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_layout(&self, window_index: usize, layout: &str) -> Result<()> {
|
||||
fn apply_layout(&self, window_target: &str, layout: &str) -> Result<()> {
|
||||
Command::new("tmux")
|
||||
.args([
|
||||
"select-layout",
|
||||
"-t",
|
||||
&format!("{}:{}", self.session_name, window_index),
|
||||
layout,
|
||||
])
|
||||
.args(["select-layout", "-t", window_target, layout])
|
||||
.status()
|
||||
.with_context(|| format!("Failed to apply layout: {}", layout))?;
|
||||
|
||||
|
||||
@@ -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,
|
||||
cache_enabled: true,
|
||||
cache_ttl_hours: 24,
|
||||
update_check_interval_hours: 24,
|
||||
default_session: SessionConfig { windows: vec![] },
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user