forked from cinco/tmuxido
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f72128a25 | ||
|
|
cc16cde540 | ||
|
|
db08840b64 | ||
|
|
4ecdf96db8 | ||
|
|
b05c188477 | ||
|
|
75d66cd47c | ||
|
|
973042ce7d | ||
|
|
0f27bedc94 | ||
|
|
da6311bc53 |
+42
@@ -109,3 +109,45 @@ steps:
|
||||
depends_on:
|
||||
- build-x86_64
|
||||
- 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,33 @@ 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.7.1] - 2026-03-01
|
||||
|
||||
### Fixed
|
||||
- Interactive setup wizard now asks for a tmux layout when a window has 2 or more panes
|
||||
- Layout selection shown in post-wizard summary
|
||||
|
||||
### Changed
|
||||
- README: Added ASCII art previews for each available tmux layout
|
||||
|
||||
## [0.7.0] - 2026-03-01
|
||||
|
||||
### Changed
|
||||
- `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
|
||||
|
||||
Generated
+1
-1
@@ -864,7 +864,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tmuxido"
|
||||
version = "0.5.2"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tmuxido"
|
||||
version = "0.5.2"
|
||||
version = "0.7.1"
|
||||
edition = "2024"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -30,7 +30,7 @@ A Rust-based tool to quickly find and open projects in tmux using fzf. No extern
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
curl -fsSL https://git.cincoeuzebio.com/cinco/Tmuxido/raw/branch/main/install.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/cinco/tmuxido/main/install.sh | sh
|
||||
```
|
||||
|
||||
Installs the latest release binary to `~/.local/bin/tmuxido`. On first run, the config file is created automatically at `~/.config/tmuxido/tmuxido.toml`.
|
||||
@@ -123,7 +123,7 @@ tmuxido --help
|
||||
3. Presents them using fzf for selection
|
||||
4. Creates or switches to a tmux session for the selected project
|
||||
5. If a `.tmuxido.toml` config exists in the project, uses it to set up custom windows and panes
|
||||
6. Otherwise, creates a default session with two windows: "editor" and "terminal"
|
||||
6. Otherwise, uses the default session config from `~/.config/tmuxido/tmuxido.toml` (configured interactively on first run)
|
||||
|
||||
## Caching
|
||||
|
||||
@@ -158,11 +158,61 @@ panes = []
|
||||
|
||||
### Available Layouts
|
||||
|
||||
- `main-horizontal` - Main pane on top, others below
|
||||
- `main-vertical` - Main pane on left, others on right
|
||||
- `tiled` - All panes tiled
|
||||
- `even-horizontal` - All panes in horizontal row
|
||||
- `even-vertical` - All panes in vertical column
|
||||
**`main-horizontal`** — Main pane on top, others below
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ │
|
||||
│ main pane │
|
||||
│ │
|
||||
├──────────┬───────────┤
|
||||
│ pane 2 │ pane 3 │
|
||||
└──────────┴───────────┘
|
||||
```
|
||||
|
||||
**`main-vertical`** — Main pane on left, others on right
|
||||
|
||||
```
|
||||
┌─────────────┬────────┐
|
||||
│ │ pane 2 │
|
||||
│ main pane ├────────┤
|
||||
│ │ pane 3 │
|
||||
│ ├────────┤
|
||||
│ │ pane 4 │
|
||||
└─────────────┴────────┘
|
||||
```
|
||||
|
||||
**`tiled`** — All panes tiled equally
|
||||
|
||||
```
|
||||
┌───────────┬──────────┐
|
||||
│ pane 1 │ pane 2 │
|
||||
├───────────┼──────────┤
|
||||
│ pane 3 │ pane 4 │
|
||||
└───────────┴──────────┘
|
||||
```
|
||||
|
||||
**`even-horizontal`** — All panes side by side
|
||||
|
||||
```
|
||||
┌────────┬────────┬────────┐
|
||||
│ │ │ │
|
||||
│ pane 1 │ pane 2 │ pane 3 │
|
||||
│ │ │ │
|
||||
└────────┴────────┴────────┘
|
||||
```
|
||||
|
||||
**`even-vertical`** — All panes stacked
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ pane 1 │
|
||||
├──────────────────────┤
|
||||
│ pane 2 │
|
||||
├──────────────────────┤
|
||||
│ pane 3 │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
### Panes
|
||||
|
||||
|
||||
+7
-4
@@ -1,8 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
REPO="cinco/Tmuxido"
|
||||
BASE_URL="https://git.cincoeuzebio.com"
|
||||
REPO="cinco/tmuxido"
|
||||
BASE_URL="https://github.com"
|
||||
API_URL="https://api.github.com"
|
||||
INSTALL_DIR="$HOME/.local/bin"
|
||||
|
||||
arch=$(uname -m)
|
||||
@@ -12,8 +13,10 @@ case "$arch" in
|
||||
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
tag=$(curl -fsSL "$BASE_URL/api/v1/repos/$REPO/releases?limit=1&page=1" \
|
||||
| grep -o '"tag_name":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
tag=$(curl -fsSL \
|
||||
-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; }
|
||||
|
||||
|
||||
+30
-2
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -174,14 +181,19 @@ impl Config {
|
||||
window_names
|
||||
};
|
||||
|
||||
// Configure panes for each window
|
||||
// Configure panes and layout for each window
|
||||
let mut windows = Vec::new();
|
||||
for name in names {
|
||||
let panes = Self::prompt_for_panes(&name)?;
|
||||
let layout = if panes.len() > 1 {
|
||||
ui::render_layout_prompt(&name, panes.len())?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
windows.push(crate::session::Window {
|
||||
name,
|
||||
panes,
|
||||
layout: None,
|
||||
layout,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -229,6 +241,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 +258,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]
|
||||
@@ -339,6 +353,20 @@ mod tests {
|
||||
assert_eq!(result, vec!["editor", "terminal", "server"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_use_ui_parse_functions_for_layout() {
|
||||
assert_eq!(ui::parse_layout_input(""), None);
|
||||
assert_eq!(
|
||||
ui::parse_layout_input("1"),
|
||||
Some("main-horizontal".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
ui::parse_layout_input("main-vertical"),
|
||||
Some("main-vertical".to_string())
|
||||
);
|
||||
assert_eq!(ui::parse_layout_input("invalid"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_parse_config_with_windows_and_panes() {
|
||||
let toml_str = r#"
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
+41
-18
@@ -3,8 +3,9 @@ use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
const REPO: &str = "cinco/Tmuxido";
|
||||
const BASE_URL: &str = "https://git.cincoeuzebio.com";
|
||||
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 {
|
||||
@@ -26,12 +27,27 @@ fn detect_arch() -> Result<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch latest release tag from Gitea API
|
||||
fn fetch_latest_tag() -> Result<String> {
|
||||
let url = format!("{}/api/v1/repos/{}/releases?limit=1&page=1", BASE_URL, REPO);
|
||||
/// 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", &url])
|
||||
.args([
|
||||
"-fsSL",
|
||||
"-H",
|
||||
"Accept: application/vnd.github.v3+json",
|
||||
&url,
|
||||
])
|
||||
.output()
|
||||
.context("Failed to execute curl. Make sure curl is installed.")?;
|
||||
|
||||
@@ -42,17 +58,7 @@ fn fetch_latest_tag() -> Result<String> {
|
||||
));
|
||||
}
|
||||
|
||||
let response = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Parse JSON response to extract tag_name
|
||||
let tag: serde_json::Value =
|
||||
serde_json::from_str(&response).context("Failed to parse release API response")?;
|
||||
|
||||
tag.get(0)
|
||||
.and_then(|r| r.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"))
|
||||
parse_latest_tag(&String::from_utf8_lossy(&output.stdout))
|
||||
}
|
||||
|
||||
/// Get path to current executable
|
||||
@@ -166,7 +172,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())
|
||||
@@ -211,6 +217,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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!(
|
||||
|
||||
@@ -133,6 +133,12 @@ pub fn render_config_created(
|
||||
println!("{}", label_style.render(" 🪟 Default Windows:"));
|
||||
for window in windows {
|
||||
println!(" {}", window_style.render(&format!("◦ {}", window.name)));
|
||||
if let Some(layout) = &window.layout {
|
||||
println!(
|
||||
"{}",
|
||||
info_style.render(&format!(" └─ layout: {}", layout))
|
||||
);
|
||||
}
|
||||
if !window.panes.is_empty() {
|
||||
for (i, pane) in window.panes.iter().enumerate() {
|
||||
let pane_display = if pane.is_empty() {
|
||||
@@ -299,6 +305,67 @@ pub fn render_panes_prompt(window_name: &str) -> Result<String> {
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
/// Renders a prompt for the layout of a window with multiple panes
|
||||
pub fn render_layout_prompt(window_name: &str, pane_count: usize) -> Result<Option<String>> {
|
||||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||||
let window_style = Style::new().bold(true).foreground(color_purple());
|
||||
let label_style = Style::new().foreground(color_blue());
|
||||
|
||||
println!();
|
||||
println!(
|
||||
" Layout for window {} ({} panes):",
|
||||
window_style.render(window_name),
|
||||
label_style.render(&pane_count.to_string())
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" Choose a pane layout (leave empty for no layout):")
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" 1. main-horizontal — main pane on top, others below")
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" 2. main-vertical — main pane on left, others on right")
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" 3. tiled — all panes tiled equally")
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" 4. even-horizontal — all panes side by side")
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" 5. even-vertical — all panes stacked vertically")
|
||||
);
|
||||
print!(" {} ", prompt_style.render("❯ Layout (1-5 or name):"));
|
||||
io::stdout().flush().context("Failed to flush stdout")?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin()
|
||||
.read_line(&mut input)
|
||||
.context("Failed to read input")?;
|
||||
|
||||
Ok(parse_layout_input(input.trim()))
|
||||
}
|
||||
|
||||
/// Parse layout input: accepts number (1-5) or layout name; returns None for empty/invalid
|
||||
pub fn parse_layout_input(input: &str) -> Option<String> {
|
||||
match input.trim() {
|
||||
"" => None,
|
||||
"1" | "main-horizontal" => Some("main-horizontal".to_string()),
|
||||
"2" | "main-vertical" => Some("main-vertical".to_string()),
|
||||
"3" | "tiled" => Some("tiled".to_string()),
|
||||
"4" | "even-horizontal" => Some("even-horizontal".to_string()),
|
||||
"5" | "even-vertical" => Some("even-vertical".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a prompt for a pane command
|
||||
pub fn render_pane_command_prompt(pane_name: &str) -> Result<String> {
|
||||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||||
@@ -531,6 +598,50 @@ mod tests {
|
||||
render_config_created(&vec!["~/Projects".to_string()], 5, true, 24, &windows);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_none_for_empty_layout_input() {
|
||||
assert_eq!(parse_layout_input(""), None);
|
||||
assert_eq!(parse_layout_input(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_parse_layout_by_number() {
|
||||
assert_eq!(parse_layout_input("1"), Some("main-horizontal".to_string()));
|
||||
assert_eq!(parse_layout_input("2"), Some("main-vertical".to_string()));
|
||||
assert_eq!(parse_layout_input("3"), Some("tiled".to_string()));
|
||||
assert_eq!(parse_layout_input("4"), Some("even-horizontal".to_string()));
|
||||
assert_eq!(parse_layout_input("5"), Some("even-vertical".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_parse_layout_by_name() {
|
||||
assert_eq!(
|
||||
parse_layout_input("main-horizontal"),
|
||||
Some("main-horizontal".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_layout_input("main-vertical"),
|
||||
Some("main-vertical".to_string())
|
||||
);
|
||||
assert_eq!(parse_layout_input("tiled"), Some("tiled".to_string()));
|
||||
assert_eq!(
|
||||
parse_layout_input("even-horizontal"),
|
||||
Some("even-horizontal".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_layout_input("even-vertical"),
|
||||
Some("even-vertical".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_none_for_invalid_layout_input() {
|
||||
assert_eq!(parse_layout_input("6"), None);
|
||||
assert_eq!(parse_layout_input("0"), None);
|
||||
assert_eq!(parse_layout_input("unknown"), None);
|
||||
assert_eq!(parse_layout_input("horizontal"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_config_created_with_disabled_cache_should_not_panic() {
|
||||
let windows = vec![Window {
|
||||
|
||||
@@ -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