Skip to content

Commit d0ee25a

Browse files
MagicalTuxclaude
andcommitted
feat: add configurable Tools menu with external tool shortcuts
Add a Tools menu to both macOS and GTK4 with configurable external tool shortcuts (e.g. "Open in Finder", "Open in VS Code"). Shortcuts are stored in platform-specific config files and can be edited in the Preferences dialog. Each shortcut launches its command in the active terminal's working directory. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8504730 commit d0ee25a

File tree

5 files changed

+576
-2
lines changed

5 files changed

+576
-2
lines changed

crates/cterm-app/src/config.rs

Lines changed: 200 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
//! Handles loading, saving, and managing configuration files.
44
55
use std::collections::HashMap;
6-
use std::path::PathBuf;
6+
use std::path::{Path, PathBuf};
77

88
use directories::ProjectDirs;
99
use serde::{Deserialize, Serialize};
@@ -903,6 +903,150 @@ pub fn save_config_with_sync(config: &Config, sync: bool) -> Result<(), ConfigEr
903903
Ok(())
904904
}
905905

906+
/// An external tool shortcut entry for the Tools menu
907+
#[derive(Debug, Clone, Serialize, Deserialize)]
908+
pub struct ToolShortcutEntry {
909+
pub name: String,
910+
pub command: String,
911+
#[serde(default)]
912+
pub args: Vec<String>,
913+
}
914+
915+
impl ToolShortcutEntry {
916+
/// Execute this shortcut in the given working directory.
917+
/// Spawns the process detached (stdin/stdout/stderr null).
918+
pub fn execute(&self, cwd: &Path) -> Result<(), std::io::Error> {
919+
std::process::Command::new(&self.command)
920+
.args(&self.args)
921+
.current_dir(cwd)
922+
.stdin(std::process::Stdio::null())
923+
.stdout(std::process::Stdio::null())
924+
.stderr(std::process::Stdio::null())
925+
.spawn()?;
926+
Ok(())
927+
}
928+
}
929+
930+
/// Get the platform-specific tool shortcuts file path
931+
pub fn tool_shortcuts_path() -> Option<PathBuf> {
932+
let filename = if cfg!(target_os = "macos") {
933+
"shortcuts_macos.toml"
934+
} else if cfg!(target_os = "windows") {
935+
"shortcuts_windows.toml"
936+
} else {
937+
"shortcuts_linux.toml"
938+
};
939+
config_dir().map(|p| p.join(filename))
940+
}
941+
942+
/// Return default tool shortcuts for the current platform
943+
pub fn default_tool_shortcuts() -> Vec<ToolShortcutEntry> {
944+
#[cfg(target_os = "macos")]
945+
{
946+
vec![
947+
ToolShortcutEntry {
948+
name: "Open in Finder".into(),
949+
command: "open".into(),
950+
args: vec![".".into()],
951+
},
952+
ToolShortcutEntry {
953+
name: "Open in Xcode".into(),
954+
command: "xed".into(),
955+
args: vec![".".into()],
956+
},
957+
ToolShortcutEntry {
958+
name: "Open in VS Code".into(),
959+
command: "code".into(),
960+
args: vec![".".into()],
961+
},
962+
ToolShortcutEntry {
963+
name: "Open in Terminal".into(),
964+
command: "open".into(),
965+
args: vec!["-a".into(), "Terminal".into(), ".".into()],
966+
},
967+
]
968+
}
969+
#[cfg(target_os = "windows")]
970+
{
971+
vec![
972+
ToolShortcutEntry {
973+
name: "Open in Explorer".into(),
974+
command: "explorer".into(),
975+
args: vec![".".into()],
976+
},
977+
ToolShortcutEntry {
978+
name: "Open in VS Code".into(),
979+
command: "code".into(),
980+
args: vec![".".into()],
981+
},
982+
ToolShortcutEntry {
983+
name: "Open in PowerShell".into(),
984+
command: "powershell".into(),
985+
args: vec!["-NoExit".into(), "-Command".into(), "cd .".into()],
986+
},
987+
]
988+
}
989+
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
990+
{
991+
vec![
992+
ToolShortcutEntry {
993+
name: "Open File Manager".into(),
994+
command: "xdg-open".into(),
995+
args: vec![".".into()],
996+
},
997+
ToolShortcutEntry {
998+
name: "Open in VS Code".into(),
999+
command: "code".into(),
1000+
args: vec![".".into()],
1001+
},
1002+
ToolShortcutEntry {
1003+
name: "Open in Terminal".into(),
1004+
command: "x-terminal-emulator".into(),
1005+
args: vec![".".into()],
1006+
},
1007+
]
1008+
}
1009+
}
1010+
1011+
/// Load tool shortcuts from the platform-specific config file.
1012+
/// Returns defaults if the file doesn't exist.
1013+
pub fn load_tool_shortcuts() -> Result<Vec<ToolShortcutEntry>, ConfigError> {
1014+
let path = tool_shortcuts_path().ok_or(ConfigError::NoConfigDir)?;
1015+
1016+
if !path.exists() {
1017+
return Ok(default_tool_shortcuts());
1018+
}
1019+
1020+
let content = std::fs::read_to_string(&path)?;
1021+
1022+
#[derive(Deserialize)]
1023+
struct ToolShortcutsFile {
1024+
tools: Vec<ToolShortcutEntry>,
1025+
}
1026+
1027+
let file: ToolShortcutsFile = toml::from_str(&content)?;
1028+
Ok(file.tools)
1029+
}
1030+
1031+
/// Save tool shortcuts to the platform-specific config file
1032+
pub fn save_tool_shortcuts(tools: &[ToolShortcutEntry]) -> Result<(), ConfigError> {
1033+
let dir = config_dir().ok_or(ConfigError::NoConfigDir)?;
1034+
std::fs::create_dir_all(&dir)?;
1035+
1036+
let path = tool_shortcuts_path().ok_or(ConfigError::NoConfigDir)?;
1037+
1038+
#[derive(Serialize)]
1039+
struct ToolShortcutsFile<'a> {
1040+
tools: &'a [ToolShortcutEntry],
1041+
}
1042+
1043+
let file = ToolShortcutsFile { tools };
1044+
let content = toml::to_string_pretty(&file)?;
1045+
std::fs::write(&path, content)?;
1046+
1047+
Ok(())
1048+
}
1049+
9061050
#[cfg(test)]
9071051
mod tests {
9081052
use super::*;
@@ -1260,4 +1404,59 @@ mod tests {
12601404
let visibility = TabBarVisibility::default();
12611405
assert!(matches!(visibility, TabBarVisibility::Always));
12621406
}
1407+
1408+
#[test]
1409+
fn test_default_tool_shortcuts_not_empty() {
1410+
let shortcuts = default_tool_shortcuts();
1411+
assert!(!shortcuts.is_empty());
1412+
// First entry should always have a name and command
1413+
assert!(!shortcuts[0].name.is_empty());
1414+
assert!(!shortcuts[0].command.is_empty());
1415+
}
1416+
1417+
#[test]
1418+
fn test_tool_shortcut_serialize() {
1419+
let shortcut = ToolShortcutEntry {
1420+
name: "Test".into(),
1421+
command: "echo".into(),
1422+
args: vec!["hello".into()],
1423+
};
1424+
let serialized = toml::to_string(&shortcut).unwrap();
1425+
assert!(serialized.contains("name = \"Test\""));
1426+
assert!(serialized.contains("command = \"echo\""));
1427+
}
1428+
1429+
#[test]
1430+
fn test_tool_shortcut_deserialize() {
1431+
let toml_str = r#"
1432+
[[tools]]
1433+
name = "Open Finder"
1434+
command = "open"
1435+
args = ["."]
1436+
"#;
1437+
1438+
#[derive(Deserialize)]
1439+
struct File {
1440+
tools: Vec<ToolShortcutEntry>,
1441+
}
1442+
1443+
let file: File = toml::from_str(toml_str).unwrap();
1444+
assert_eq!(file.tools.len(), 1);
1445+
assert_eq!(file.tools[0].name, "Open Finder");
1446+
assert_eq!(file.tools[0].command, "open");
1447+
assert_eq!(file.tools[0].args, vec!["."]);
1448+
}
1449+
1450+
#[test]
1451+
fn test_tool_shortcut_deserialize_no_args() {
1452+
let toml_str = r#"
1453+
name = "Test"
1454+
command = "ls"
1455+
"#;
1456+
1457+
let entry: ToolShortcutEntry = toml::from_str(toml_str).unwrap();
1458+
assert_eq!(entry.name, "Test");
1459+
assert_eq!(entry.command, "ls");
1460+
assert!(entry.args.is_empty());
1461+
}
12631462
}

crates/cterm-cocoa/src/app.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,71 @@ define_class!(
479479
}
480480
}
481481

482+
#[unsafe(method(runToolShortcut:))]
483+
fn action_run_tool_shortcut(&self, sender: Option<&objc2::runtime::AnyObject>) {
484+
use objc2_app_kit::NSMenuItem;
485+
486+
if let Some(sender) = sender {
487+
let item: &NSMenuItem = unsafe { &*(sender as *const _ as *const NSMenuItem) };
488+
let index = item.tag() as usize;
489+
490+
if let Ok(shortcuts) = cterm_app::config::load_tool_shortcuts() {
491+
if let Some(shortcut) = shortcuts.get(index) {
492+
// Get CWD from active terminal in the key window
493+
let mtm = MainThreadMarker::from(self);
494+
let app = NSApplication::sharedApplication(mtm);
495+
let cwd = app.keyWindow().and_then(|key_window| {
496+
let is_cterm: bool = unsafe {
497+
msg_send![&key_window, isKindOfClass: objc2::class!(CtermWindow)]
498+
};
499+
if is_cterm {
500+
let cterm_window: &CtermWindow = unsafe {
501+
&*(&*key_window as *const NSWindow as *const CtermWindow)
502+
};
503+
#[cfg(unix)]
504+
{
505+
cterm_window
506+
.active_terminal()
507+
.and_then(|t| t.foreground_cwd())
508+
}
509+
#[cfg(not(unix))]
510+
{
511+
let _ = cterm_window;
512+
None
513+
}
514+
} else {
515+
None
516+
}
517+
});
518+
519+
let cwd = cwd.unwrap_or_else(|| {
520+
std::env::var("HOME").unwrap_or_else(|_| "/".to_string())
521+
});
522+
523+
if let Err(e) =
524+
shortcut.execute(std::path::Path::new(&cwd))
525+
{
526+
// Show error alert
527+
let alert = objc2_app_kit::NSAlert::new(mtm);
528+
alert.setMessageText(&NSString::from_str(&format!(
529+
"Failed to launch \"{}\"",
530+
shortcut.name
531+
)));
532+
alert.setInformativeText(&NSString::from_str(&format!(
533+
"Command '{}' failed: {}",
534+
shortcut.command, e
535+
)));
536+
alert.setAlertStyle(
537+
objc2_app_kit::NSAlertStyle::Warning,
538+
);
539+
alert.addButtonWithTitle(&NSString::from_str("OK"));
540+
alert.runModal();
541+
}
542+
}
543+
}
544+
}
545+
}
546+
482547
#[unsafe(method(newWindow:))]
483548
fn action_new_window(&self, _sender: Option<&objc2::runtime::AnyObject>) {
484549
use objc2_app_kit::NSWindowTabbingMode;

crates/cterm-cocoa/src/menu.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ pub fn create_menu_bar(mtm: MainThreadMarker) -> Retained<NSMenu> {
6161
// Terminal menu
6262
menu_bar.addItem(&create_terminal_menu(mtm));
6363

64+
// Tools menu
65+
menu_bar.addItem(&create_tools_menu(mtm));
66+
6467
// Window menu
6568
menu_bar.addItem(&create_window_menu(mtm));
6669

@@ -484,6 +487,53 @@ fn create_terminal_menu(mtm: MainThreadMarker) -> Retained<NSMenuItem> {
484487
menu_item
485488
}
486489

490+
fn create_tools_menu(mtm: MainThreadMarker) -> Retained<NSMenuItem> {
491+
let menu = NSMenu::new(mtm);
492+
menu.setTitle(&NSString::from_str("Tools"));
493+
494+
populate_tools_menu(&menu, mtm);
495+
496+
let menu_item = NSMenuItem::new(mtm);
497+
menu_item.setSubmenu(Some(&menu));
498+
menu_item
499+
}
500+
501+
/// Populate (or repopulate) the Tools menu with shortcut entries
502+
fn populate_tools_menu(menu: &NSMenu, mtm: MainThreadMarker) {
503+
if let Ok(shortcuts) = cterm_app::config::load_tool_shortcuts() {
504+
for (i, shortcut) in shortcuts.iter().enumerate() {
505+
let item = NSMenuItem::new(mtm);
506+
item.setTitle(&NSString::from_str(&shortcut.name));
507+
unsafe { item.setAction(Some(sel!(runToolShortcut:))) };
508+
item.setTag(i as isize);
509+
menu.addItem(&item);
510+
}
511+
}
512+
}
513+
514+
/// Rebuild the Tools menu items (called after preferences save)
515+
pub fn rebuild_tools_menu(mtm: MainThreadMarker) {
516+
use objc2_app_kit::NSApplication;
517+
518+
let app = NSApplication::sharedApplication(mtm);
519+
if let Some(main_menu) = app.mainMenu() {
520+
// Find the "Tools" menu
521+
let tools_title = NSString::from_str("Tools");
522+
let count = main_menu.numberOfItems();
523+
for i in 0..count {
524+
if let Some(item) = main_menu.itemAtIndex(i) {
525+
if let Some(submenu) = item.submenu() {
526+
if submenu.title().to_string() == tools_title.to_string() {
527+
submenu.removeAllItems();
528+
populate_tools_menu(&submenu, mtm);
529+
return;
530+
}
531+
}
532+
}
533+
}
534+
}
535+
}
536+
487537
fn create_window_menu(mtm: MainThreadMarker) -> Retained<NSMenuItem> {
488538
let menu = NSMenu::new(mtm);
489539
menu.setTitle(&NSString::from_str("Window"));

0 commit comments

Comments
 (0)