✨ feat: add desktop integration wizard and --create-desktop-shortcut
- install_desktop_integration_to() writes .desktop and downloads icon - setup_desktop_integration_wizard() prompts and installs to XDG paths - --create-desktop-shortcut flag to re-run at any time - First-run wizard now also offers desktop integration after shortcut setup - 164 tests passing
This commit is contained in:
parent
3aacf30697
commit
d7246298b1
@ -13,8 +13,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- GNOME: registers a custom keybinding via `gsettings`
|
||||
- KDE: appends a `[tmuxido]` section to `~/.config/kglobalshortcutsrc`
|
||||
- Conflict detection per DE (Hyprland via `hyprctl binds -j`, KDE via config file, GNOME via gsettings); suggests next free combo from a fallback list
|
||||
- `--create-desktop-shortcut` flag to (re-)install the `.desktop` entry and icon at any time
|
||||
- `shortcut` module (`src/shortcut.rs`) with full unit and integration test coverage
|
||||
- Icon and `.desktop` file installed by `install.sh` for launcher and window-rule integration
|
||||
- Icon and `.desktop` file installed by `install.sh` and offered in the first-run wizard
|
||||
|
||||
## [0.7.1] - 2026-03-01
|
||||
|
||||
|
||||
@ -112,7 +112,12 @@ Set up a desktop keyboard shortcut (Hyprland, GNOME, KDE):
|
||||
tmuxido --setup-shortcut
|
||||
```
|
||||
|
||||
This is also offered automatically on first run. Re-run it any time to reconfigure the shortcut or after switching desktop environments.
|
||||
Install the `.desktop` entry and icon (so tmuxido appears in app launchers like Walker/Rofi):
|
||||
```bash
|
||||
tmuxido --create-desktop-shortcut
|
||||
```
|
||||
|
||||
Both are also offered automatically on first run. Re-run them any time to reconfigure.
|
||||
|
||||
View help:
|
||||
```bash
|
||||
|
||||
@ -130,6 +130,11 @@ impl Config {
|
||||
if let Err(e) = crate::shortcut::setup_shortcut_wizard() {
|
||||
eprintln!("Warning: shortcut setup failed: {}", e);
|
||||
}
|
||||
|
||||
// Offer to install .desktop entry + icon (best-effort, non-fatal)
|
||||
if let Err(e) = crate::shortcut::setup_desktop_integration_wizard() {
|
||||
eprintln!("Warning: desktop integration failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(config_path)
|
||||
|
||||
@ -20,6 +20,10 @@ pub fn setup_shortcut_wizard() -> Result<()> {
|
||||
shortcut::setup_shortcut_wizard()
|
||||
}
|
||||
|
||||
pub fn setup_desktop_integration_wizard() -> Result<()> {
|
||||
shortcut::setup_desktop_integration_wizard()
|
||||
}
|
||||
|
||||
pub fn show_cache_status(config: &Config) -> Result<()> {
|
||||
if !config.cache_enabled {
|
||||
println!("Cache is disabled in configuration");
|
||||
|
||||
14
src/main.rs
14
src/main.rs
@ -7,7 +7,10 @@ use tmuxido::config::Config;
|
||||
use tmuxido::deps::ensure_dependencies;
|
||||
use tmuxido::self_update;
|
||||
use tmuxido::update_check;
|
||||
use tmuxido::{get_projects, launch_tmux_session, setup_shortcut_wizard, show_cache_status};
|
||||
use tmuxido::{
|
||||
get_projects, launch_tmux_session, setup_desktop_integration_wizard, setup_shortcut_wizard,
|
||||
show_cache_status,
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
@ -34,6 +37,10 @@ struct Args {
|
||||
/// Set up a keyboard shortcut to launch tmuxido
|
||||
#[arg(long)]
|
||||
setup_shortcut: bool,
|
||||
|
||||
/// Install the .desktop entry and icon for app launcher integration
|
||||
#[arg(long)]
|
||||
create_desktop_shortcut: bool,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@ -49,6 +56,11 @@ fn main() -> Result<()> {
|
||||
return setup_shortcut_wizard();
|
||||
}
|
||||
|
||||
// Handle standalone desktop integration setup
|
||||
if args.create_desktop_shortcut {
|
||||
return setup_desktop_integration_wizard();
|
||||
}
|
||||
|
||||
// Check that fzf and tmux are installed; offer to install if missing
|
||||
ensure_dependencies()?;
|
||||
|
||||
|
||||
160
src/shortcut.rs
160
src/shortcut.rs
@ -594,6 +594,122 @@ pub fn setup_shortcut_wizard() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Desktop integration (.desktop file + icon)
|
||||
// ============================================================================
|
||||
|
||||
const ICON_URL: &str = "https://raw.githubusercontent.com/cinco/tmuxido/refs/heads/main/docs/assets/tmuxido-icon_96.png";
|
||||
|
||||
const DESKTOP_CONTENT: &str = "[Desktop Entry]
|
||||
Name=Tmuxido
|
||||
Comment=Quickly find and open projects in tmux
|
||||
Exec=tmuxido
|
||||
Icon=tmuxido
|
||||
Type=Application
|
||||
Categories=Development;Utility;
|
||||
Terminal=true
|
||||
Keywords=tmux;project;fzf;dev;
|
||||
StartupWMClass=tmuxido
|
||||
";
|
||||
|
||||
/// Path where the .desktop entry will be installed
|
||||
pub fn desktop_file_path() -> Result<PathBuf> {
|
||||
let data_dir = dirs::data_dir().context("Could not determine data directory")?;
|
||||
Ok(data_dir.join("applications").join("tmuxido.desktop"))
|
||||
}
|
||||
|
||||
/// Path where the 96×96 icon will be installed
|
||||
pub fn icon_install_path() -> Result<PathBuf> {
|
||||
let data_dir = dirs::data_dir().context("Could not determine data directory")?;
|
||||
Ok(data_dir
|
||||
.join("icons")
|
||||
.join("hicolor")
|
||||
.join("96x96")
|
||||
.join("apps")
|
||||
.join("tmuxido.png"))
|
||||
}
|
||||
|
||||
/// Result of a desktop integration install
|
||||
pub struct DesktopInstallResult {
|
||||
pub desktop_path: PathBuf,
|
||||
pub icon_path: PathBuf,
|
||||
pub icon_downloaded: bool,
|
||||
}
|
||||
|
||||
/// Write the .desktop file and download the icon to the given paths.
|
||||
/// Icon download is best-effort — does not fail if curl or network is unavailable.
|
||||
pub fn install_desktop_integration_to(
|
||||
desktop_path: &Path,
|
||||
icon_path: &Path,
|
||||
) -> Result<DesktopInstallResult> {
|
||||
// Write .desktop
|
||||
if let Some(parent) = desktop_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
||||
}
|
||||
std::fs::write(desktop_path, DESKTOP_CONTENT)
|
||||
.with_context(|| format!("Failed to write {}", desktop_path.display()))?;
|
||||
|
||||
// Download icon (best-effort via curl)
|
||||
let icon_downloaded = (|| -> Option<()> {
|
||||
if let Some(parent) = icon_path.parent() {
|
||||
std::fs::create_dir_all(parent).ok()?;
|
||||
}
|
||||
std::process::Command::new("curl")
|
||||
.args(["-fsSL", ICON_URL, "-o", &icon_path.to_string_lossy()])
|
||||
.status()
|
||||
.ok()?
|
||||
.success()
|
||||
.then_some(())
|
||||
})()
|
||||
.is_some();
|
||||
|
||||
// Refresh desktop database (best-effort)
|
||||
if let Some(apps_dir) = desktop_path.parent() {
|
||||
let _ = std::process::Command::new("update-desktop-database")
|
||||
.arg(apps_dir)
|
||||
.status();
|
||||
}
|
||||
|
||||
// Refresh icon cache (best-effort)
|
||||
if icon_downloaded {
|
||||
// Navigate up from …/96x96/apps → …/icons/hicolor
|
||||
let hicolor_dir = icon_path
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.and_then(|p| p.parent());
|
||||
if let Some(dir) = hicolor_dir {
|
||||
let _ = std::process::Command::new("gtk-update-icon-cache")
|
||||
.args(["-f", "-t", &dir.to_string_lossy()])
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(DesktopInstallResult {
|
||||
desktop_path: desktop_path.to_path_buf(),
|
||||
icon_path: icon_path.to_path_buf(),
|
||||
icon_downloaded,
|
||||
})
|
||||
}
|
||||
|
||||
/// Install .desktop and icon to the standard XDG locations
|
||||
pub fn install_desktop_integration() -> Result<DesktopInstallResult> {
|
||||
install_desktop_integration_to(&desktop_file_path()?, &icon_install_path()?)
|
||||
}
|
||||
|
||||
/// Interactive wizard that asks the user and then installs desktop integration
|
||||
pub fn setup_desktop_integration_wizard() -> Result<()> {
|
||||
crate::ui::render_section_header("Desktop Integration");
|
||||
|
||||
if !crate::ui::render_desktop_integration_prompt()? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let result = install_desktop_integration()?;
|
||||
crate::ui::render_desktop_integration_success(&result);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
@ -821,4 +937,48 @@ mod tests {
|
||||
let c = KeyCombo::parse("super+shift+t").unwrap();
|
||||
assert_eq!(c.normalized(), "SUPER+SHIFT+T");
|
||||
}
|
||||
|
||||
// --- desktop integration ---
|
||||
|
||||
#[test]
|
||||
fn should_write_desktop_file_to_given_path() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let desktop = dir.path().join("apps").join("tmuxido.desktop");
|
||||
let icon = dir.path().join("icons").join("tmuxido.png");
|
||||
|
||||
let result = install_desktop_integration_to(&desktop, &icon).unwrap();
|
||||
|
||||
assert!(result.desktop_path.exists());
|
||||
let content = std::fs::read_to_string(&result.desktop_path).unwrap();
|
||||
assert!(content.contains("[Desktop Entry]"));
|
||||
assert!(content.contains("Exec=tmuxido"));
|
||||
assert!(content.contains("Icon=tmuxido"));
|
||||
assert!(content.contains("Terminal=true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_create_parent_directories_for_desktop_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let desktop = dir
|
||||
.path()
|
||||
.join("nested")
|
||||
.join("apps")
|
||||
.join("tmuxido.desktop");
|
||||
let icon = dir.path().join("icons").join("tmuxido.png");
|
||||
|
||||
install_desktop_integration_to(&desktop, &icon).unwrap();
|
||||
|
||||
assert!(desktop.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_content_contains_required_fields() {
|
||||
assert!(DESKTOP_CONTENT.contains("[Desktop Entry]"));
|
||||
assert!(DESKTOP_CONTENT.contains("Name=Tmuxido"));
|
||||
assert!(DESKTOP_CONTENT.contains("Exec=tmuxido"));
|
||||
assert!(DESKTOP_CONTENT.contains("Icon=tmuxido"));
|
||||
assert!(DESKTOP_CONTENT.contains("Type=Application"));
|
||||
assert!(DESKTOP_CONTENT.contains("Terminal=true"));
|
||||
assert!(DESKTOP_CONTENT.contains("StartupWMClass=tmuxido"));
|
||||
}
|
||||
}
|
||||
|
||||
88
src/ui.rs
88
src/ui.rs
@ -551,6 +551,66 @@ pub fn render_shortcut_success(de: &str, combo: &str, details: &str, reload_hint
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Ask the user whether to install the .desktop entry and icon
|
||||
pub fn render_desktop_integration_prompt() -> Result<bool> {
|
||||
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(
|
||||
" Install a .desktop entry so tmuxido appears in app launchers (Walker, Rofi, etc.)?"
|
||||
)
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
hint_style
|
||||
.render(" Also downloads the 96×96 icon from GitHub (requires internet access).")
|
||||
);
|
||||
print!(
|
||||
" {} ",
|
||||
prompt_style.render("❯ Install desktop entry? (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")?;
|
||||
|
||||
let trimmed = input.trim().to_lowercase();
|
||||
Ok(trimmed != "n" && trimmed != "no")
|
||||
}
|
||||
|
||||
/// Render a success message after desktop integration is installed
|
||||
pub fn render_desktop_integration_success(result: &crate::shortcut::DesktopInstallResult) {
|
||||
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 warn_style = Style::new().italic(true).foreground(color_orange());
|
||||
|
||||
println!();
|
||||
println!("{}", success_style.render(" 🖥️ Desktop entry installed!"));
|
||||
println!(
|
||||
" {} {}",
|
||||
label_style.render(".desktop:"),
|
||||
value_style.render(&result.desktop_path.display().to_string())
|
||||
);
|
||||
if result.icon_downloaded {
|
||||
println!(
|
||||
" {} {}",
|
||||
label_style.render("icon:"),
|
||||
value_style.render(&result.icon_path.display().to_string())
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {}",
|
||||
warn_style.render("icon: download skipped (no network or curl unavailable)")
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
/// Parse comma-separated list into Vec<String>, filtering empty items
|
||||
@ -794,4 +854,32 @@ mod tests {
|
||||
"Reload Hyprland with Super+Shift+R to activate.",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_desktop_integration_success_should_not_panic() {
|
||||
use crate::shortcut::DesktopInstallResult;
|
||||
use std::path::PathBuf;
|
||||
let result = DesktopInstallResult {
|
||||
desktop_path: PathBuf::from("/home/user/.local/share/applications/tmuxido.desktop"),
|
||||
icon_path: PathBuf::from(
|
||||
"/home/user/.local/share/icons/hicolor/96x96/apps/tmuxido.png",
|
||||
),
|
||||
icon_downloaded: true,
|
||||
};
|
||||
render_desktop_integration_success(&result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_desktop_integration_success_without_icon_should_not_panic() {
|
||||
use crate::shortcut::DesktopInstallResult;
|
||||
use std::path::PathBuf;
|
||||
let result = DesktopInstallResult {
|
||||
desktop_path: PathBuf::from("/home/user/.local/share/applications/tmuxido.desktop"),
|
||||
icon_path: PathBuf::from(
|
||||
"/home/user/.local/share/icons/hicolor/96x96/apps/tmuxido.png",
|
||||
),
|
||||
icon_downloaded: false,
|
||||
};
|
||||
render_desktop_integration_success(&result);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
use tmuxido::shortcut::{KeyCombo, check_kde_conflict, write_hyprland_binding, write_kde_shortcut};
|
||||
use tmuxido::shortcut::{
|
||||
KeyCombo, check_kde_conflict, install_desktop_integration_to, write_hyprland_binding,
|
||||
write_kde_shortcut,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn writes_hyprland_binding_to_new_file() {
|
||||
@ -126,3 +129,37 @@ fn check_kde_conflict_returns_none_when_file_missing() {
|
||||
let combo = KeyCombo::parse("Super+Shift+T").unwrap();
|
||||
assert!(check_kde_conflict(std::path::Path::new("/nonexistent/path"), &combo).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installs_desktop_file_to_given_path() {
|
||||
let dir = tempdir().unwrap();
|
||||
let desktop_path = dir.path().join("applications").join("tmuxido.desktop");
|
||||
let icon_path = dir
|
||||
.path()
|
||||
.join("icons")
|
||||
.join("hicolor")
|
||||
.join("96x96")
|
||||
.join("apps")
|
||||
.join("tmuxido.png");
|
||||
|
||||
let result = install_desktop_integration_to(&desktop_path, &icon_path).unwrap();
|
||||
|
||||
assert!(result.desktop_path.exists(), ".desktop file should exist");
|
||||
let content = fs::read_to_string(&result.desktop_path).unwrap();
|
||||
assert!(content.contains("[Desktop Entry]"));
|
||||
assert!(content.contains("Exec=tmuxido"));
|
||||
assert!(content.contains("Icon=tmuxido"));
|
||||
assert!(content.contains("Terminal=true"));
|
||||
assert!(content.contains("StartupWMClass=tmuxido"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_install_creates_parent_dirs() {
|
||||
let dir = tempdir().unwrap();
|
||||
let desktop_path = dir.path().join("a").join("b").join("tmuxido.desktop");
|
||||
let icon_path = dir.path().join("icons").join("tmuxido.png");
|
||||
|
||||
install_desktop_integration_to(&desktop_path, &icon_path).unwrap();
|
||||
|
||||
assert!(desktop_path.exists());
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user