✨ 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`
|
- GNOME: registers a custom keybinding via `gsettings`
|
||||||
- KDE: appends a `[tmuxido]` section to `~/.config/kglobalshortcutsrc`
|
- 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
|
- 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
|
- `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
|
## [0.7.1] - 2026-03-01
|
||||||
|
|
||||||
|
|||||||
@ -112,7 +112,12 @@ Set up a desktop keyboard shortcut (Hyprland, GNOME, KDE):
|
|||||||
tmuxido --setup-shortcut
|
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:
|
View help:
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -130,6 +130,11 @@ impl Config {
|
|||||||
if let Err(e) = crate::shortcut::setup_shortcut_wizard() {
|
if let Err(e) = crate::shortcut::setup_shortcut_wizard() {
|
||||||
eprintln!("Warning: shortcut setup failed: {}", e);
|
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)
|
Ok(config_path)
|
||||||
|
|||||||
@ -20,6 +20,10 @@ pub fn setup_shortcut_wizard() -> Result<()> {
|
|||||||
shortcut::setup_shortcut_wizard()
|
shortcut::setup_shortcut_wizard()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn setup_desktop_integration_wizard() -> Result<()> {
|
||||||
|
shortcut::setup_desktop_integration_wizard()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn show_cache_status(config: &Config) -> Result<()> {
|
pub fn show_cache_status(config: &Config) -> Result<()> {
|
||||||
if !config.cache_enabled {
|
if !config.cache_enabled {
|
||||||
println!("Cache is disabled in configuration");
|
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::deps::ensure_dependencies;
|
||||||
use tmuxido::self_update;
|
use tmuxido::self_update;
|
||||||
use tmuxido::update_check;
|
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)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(
|
#[command(
|
||||||
@ -34,6 +37,10 @@ struct Args {
|
|||||||
/// Set up a keyboard shortcut to launch tmuxido
|
/// Set up a keyboard shortcut to launch tmuxido
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
setup_shortcut: bool,
|
setup_shortcut: bool,
|
||||||
|
|
||||||
|
/// Install the .desktop entry and icon for app launcher integration
|
||||||
|
#[arg(long)]
|
||||||
|
create_desktop_shortcut: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
@ -49,6 +56,11 @@ fn main() -> Result<()> {
|
|||||||
return setup_shortcut_wizard();
|
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
|
// Check that fzf and tmux are installed; offer to install if missing
|
||||||
ensure_dependencies()?;
|
ensure_dependencies()?;
|
||||||
|
|
||||||
|
|||||||
160
src/shortcut.rs
160
src/shortcut.rs
@ -594,6 +594,122 @@ pub fn setup_shortcut_wizard() -> Result<()> {
|
|||||||
Ok(())
|
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
|
// Tests
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -821,4 +937,48 @@ mod tests {
|
|||||||
let c = KeyCombo::parse("super+shift+t").unwrap();
|
let c = KeyCombo::parse("super+shift+t").unwrap();
|
||||||
assert_eq!(c.normalized(), "SUPER+SHIFT+T");
|
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!();
|
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
|
/// Parse comma-separated list into Vec<String>, filtering empty items
|
||||||
@ -794,4 +854,32 @@ mod tests {
|
|||||||
"Reload Hyprland with Super+Shift+R to activate.",
|
"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 std::fs;
|
||||||
use tempfile::tempdir;
|
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]
|
#[test]
|
||||||
fn writes_hyprland_binding_to_new_file() {
|
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();
|
let combo = KeyCombo::parse("Super+Shift+T").unwrap();
|
||||||
assert!(check_kde_conflict(std::path::Path::new("/nonexistent/path"), &combo).is_none());
|
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