Compare commits

...
14 Commits
Author SHA1 Message Date
cinco 3f72128a25 🔖 chore: bump version to 0.7.1 and update CHANGELOG
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2026-03-01 05:07:06 -03:00
cinco cc16cde540 📝 docs: add ASCII art previews for each available tmux layout in README 2026-03-01 05:07:03 -03:00
cinco db08840b64 🐛 fix: ask for layout in interactive wizard when window has multiple panes
Add `render_layout_prompt` and `parse_layout_input` to ui.rs so that
the first-run wizard asks the user to choose a tmux layout (1–5 or by
name) for each window that has 2 or more panes. Previously, layout was
always silently set to None.

Also update `render_config_created` to display the chosen layout in the
post-setup summary.

Closes: layout never being set during interactive setup
2026-03-01 05:07:00 -03:00
cinco 4ecdf96db8 📝 docs: update install URL to GitHub and fix default session description 2026-03-01 05:01:04 -03:00
cinco b05c188477 🐛 fix: consolidate publish-github into single shell block
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is passing
Multiple command blocks caused $RELEASE_ID to be lost between shells.
Using ${ARCH} with braces triggered Drone variable substitution before
the shell ran, resulting in an empty arch name. Fix: single | block
with all logic, iterate over full filenames (same pattern as publish),
and use ENVIRON["DRONE_TAG"] in awk to bypass Drone substitution.
2026-03-01 04:56:36 -03:00
cinco 75d66cd47c feat: publish releases to GitHub and update install source
- Self-update now queries the GitHub Releases API (parse_latest_tag extracted for testability)
- install.sh now fetches and downloads from GitHub Releases
- Drone CI release pipeline publishes to both Gitea and GitHub via GITHUB_TOKEN secret
- Bump version to 0.7.0
2026-03-01 04:50:52 -03:00
cinco 973042ce7d 🔖 chore: bump version to 0.6.0 and update CHANGELOG
continuous-integration/drone/tag Build is passing
2026-03-01 04:07:41 -03:00
cinco 0f27bedc94 feat: periodic update check on startup
On startup, if `update_check_interval_hours` have elapsed since the last
check, 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 with injected fetcher for full testability
- Cache at ~/.cache/tmuxido/update_check.json tracks timestamp + version
- `fetch_latest_tag` and `version_compare` promoted to `pub(crate)`
- 6 unit tests covering disabled, interval not elapsed, fetch triggered,
  equal versions, update available, and current-newer edge case
2026-03-01 04:07:38 -03:00
cinco da6311bc53 feat: add update_check_interval_hours config field
Adds `update_check_interval_hours: u64` (serde default 24, 0 = disabled)
to Config struct, enabling periodic update check control via config file.
2026-03-01 04:07:31 -03:00
cinco 2b1773375a 🐛 fix: add test for asset name format and bump to 0.5.2
continuous-integration/drone/tag Build is passing
Adds a unit test that asserts detect_arch returns names prefixed
with 'tmuxido-' and suffixed with '-linux', matching what CI uploads.
2026-03-01 03:43:10 -03:00
cinco 36aaa65945 📝 docs: add changelog entry for 0.5.1
continuous-integration/drone/tag Build is passing
2026-03-01 03:38:04 -03:00
cinco 10a38a1f85 🔧 ci: delete existing release before recreating on retag
When a tag is deleted and recreated, the CI tried to POST a new
release that already existed, getting 409 and leaving RELEASE_ID
null, which caused asset uploads to fail with 405. Now checks for
an existing release by tag and deletes it before creating a new one.
2026-03-01 03:34:27 -03:00
cinco 42bdc1d409 🐛 fix: correct asset name and bump version to 0.5.1
detect_arch was returning "x86_64-linux" but CI uploads assets as
"tmuxido-x86_64-linux", causing 404 on self-update. Also bumps
Cargo.toml to 0.5.1 which was missing from the hotfix tag.
2026-03-01 03:31:10 -03:00
cinco a592c99375 🐛 fix: target tmux windows by name instead of numeric index
Removes base-index detection which was unreliable and defaulted to 0
when tmux's actual base-index was 1, causing "index in use" and
"can't find window" errors on session creation.
2026-03-01 03:17:28 -03:00
14 changed files with 607 additions and 91 deletions
+52
View File
@@ -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 '
@@ -99,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
+42
View File
@@ -4,6 +4,48 @@ 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
- 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
View File
@@ -864,7 +864,7 @@ dependencies = [
[[package]]
name = "tmuxido"
version = "0.5.0"
version = "0.7.1"
dependencies = [
"anyhow",
"clap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tmuxido"
version = "0.5.0"
version = "0.7.1"
edition = "2024"
[dev-dependencies]
+57 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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#"
+2
View File
@@ -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![] },
}
}
+4
View File
@@ -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)?;
+56 -20
View File
@@ -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 {
@@ -20,18 +21,33 @@ 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> {
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())
@@ -198,6 +204,36 @@ 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_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!(
+24 -56
View File
@@ -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))?;
+111
View File
@@ -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 {
+219
View 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);
}
}
+1
View File
@@ -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![] },
}
}