From d8a46d11bbb60fa45f78bc0e9e85f379bdbe4a6b Mon Sep 17 00:00:00 2001 From: cst8t <1810150+cst8t@users.noreply.github.com> Date: Sat, 30 May 2026 23:03:59 +0100 Subject: [PATCH 1/5] feat: improve shell launcher CLI --- README.md | 18 ++ src-tauri/Cargo.lock | 125 ++++++++ src-tauri/Cargo.toml | 2 + src-tauri/src/instance_coordinator.rs | 34 +- src-tauri/src/lib.rs | 92 ++++-- src-tauri/src/shell/cli.rs | 432 ++++++++++++++++++++------ src-tauri/src/shell/mod.rs | 10 +- src/api/commands.ts | 15 +- src/components/App.tsx | 44 ++- src/components/clone/CloneWindow.tsx | 182 ++++++----- src/i18n/locales/en/settings.json | 2 +- src/types.ts | 13 +- 12 files changed, 743 insertions(+), 226 deletions(-) diff --git a/README.md b/README.md index 4d2cede..f4412a2 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,24 @@ Build desktop bundles: npm run tauri build ``` +## Command line launcher + +Gitmun can be launched from a terminal or shell integration: + +```bash +gitmun +gitmun . +gitmun open /path/to/repo +gitmun clone git@github.com:owner/repo.git /path/to/destination +gitmun clone --to /path/to/destination +gitmun clone https://github.com/owner/repo.git --start +gitmun init /path/to/folder +gitmun --reuse-window open . +gitmun completions bash +``` + +`gitmun ` is the shorthand for opening a repository. `clone` accepts an optional repository URL or SSH path, an optional destination, and `--start` to begin cloning after the window opens. Use `--to` to set only the destination. `init` defaults to the current directory when no path is supplied. Use `--help` for the full command reference. + Linux-only helper setup (if needed): ```bash diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3b9ad31..325e5c5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -47,6 +47,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -554,6 +604,55 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "clru" version = "0.6.3" @@ -572,6 +671,12 @@ dependencies = [ "cc", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -1498,6 +1603,8 @@ name = "gitmun" version = "0.1.0" dependencies = [ "base64 0.22.1", + "clap", + "clap_complete", "gix", "gtk", "infer", @@ -3025,6 +3132,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -3796,6 +3909,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "open" version = "5.3.4" @@ -6206,6 +6325,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f642761..c18d798 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,6 +27,8 @@ reqwest = { version = "0.13", features = ["blocking", "rustls"], default-feature url = "2" md5 = "0.8" base64 = "0.22" +clap = { version = "4.5", features = ["derive"] } +clap_complete = "4.5" notify = "8" mime_guess = "2.0" infer = "0.19" diff --git a/src-tauri/src/instance_coordinator.rs b/src-tauri/src/instance_coordinator.rs index 4187897..0c39514 100644 --- a/src-tauri/src/instance_coordinator.rs +++ b/src-tauri/src/instance_coordinator.rs @@ -53,7 +53,10 @@ struct InstanceRegistry { #[serde(tag = "kind", content = "data", rename_all = "camelCase")] pub enum CoordinatorCommand { OpenRepo { path: String }, - OpenCloneWindow { destination: Option }, + InitialiseRepo { path: String }, + OpenCloneWindow { + options: crate::shell::cli::CloneStartupOptions, + }, FocusWindow { label: String }, SettingsUpdated, Ping, @@ -278,14 +281,18 @@ fn process_command(cmd: CoordinatorCommand, app: &tauri::AppHandle) -> (bool, St let _ = app.emit("instance-open-repo", path.clone()); (true, path) } - CoordinatorCommand::OpenCloneWindow { destination } => { - if let Some(path) = destination.clone() { - if let Some(state) = app.try_state::() { + CoordinatorCommand::InitialiseRepo { path } => { + let _ = app.emit("instance-initialise-repo", path.clone()); + (true, path) + } + CoordinatorCommand::OpenCloneWindow { options } => { + if options.repo_url.is_some() || options.destination.is_some() || options.start_clone { + if let Some(state) = app.try_state::() { if let Ok(mut guard) = state.0.lock() { - *guard = Some(path.clone()); + *guard = Some(options.clone()); } } - let _ = app.emit("clone-destination-updated", path); + let _ = app.emit("clone-options-updated", options); } if let Some(w) = app.get_webview_window("clone-repository") { let _ = w.show(); @@ -377,6 +384,19 @@ pub fn find_sub_window_owner(label: &str) -> Option { None } +pub fn find_recent_instance() -> Option { + let path = with_handle(|h| Ok(h.registry_path.clone())).ok()?; + let own_id = with_handle(|h| Ok(h.instance_id.clone())).ok()?; + let reg = read_registry(&path); + let cutoff = now_millis().saturating_sub(STALE_SECS * 1000); + + reg.instances + .values() + .filter(|info| info.instance_id != own_id && info.last_seen_at() >= cutoff) + .max_by_key(|info| info.last_focused) + .cloned() +} + pub fn send_command(target_port: u16, cmd: &CoordinatorCommand) -> Result<(), String> { let json = serde_json::to_string(cmd).map_err(|e| e.to_string())?; let req = format!( @@ -441,7 +461,7 @@ pub fn broadcast_settings_updated() { pub fn spawn_new_instance_open_repo(path: &str) -> Result<(), String> { let exe = std::env::current_exe().map_err(|e| format!("exe path: {e}"))?; std::process::Command::new(exe) - .arg("--open") + .arg("open") .arg(path) .spawn() .map_err(|e| format!("spawn: {e}"))?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f52a4b3..25df22c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,7 +10,8 @@ mod window_manager; use git::handler::GitService; use git::types::AvatarProviderMode; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; -use shell::cli::ShellStartupAction; +use shell::cli::{CliOutcome, CloneStartupOptions, ShellStartupAction}; +use shell::{ContextAction, WindowRouting}; use std::path::{Path, PathBuf}; #[cfg(windows)] use std::process::Command; @@ -28,7 +29,7 @@ struct FsWatcherState(Mutex>); struct StartupState(Mutex>); -pub(crate) struct PendingCloneDestination(Mutex>); +pub(crate) struct PendingCloneOptions(Mutex>); #[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x08000000; @@ -105,6 +106,34 @@ fn git_backend() -> GitBackend { *GIT_BACKEND.get_or_init(detect_git_backend) } +fn forward_reuse_window_action(action: &ShellStartupAction) -> bool { + if action.routing != Some(WindowRouting::ReuseWindow) { + return false; + } + + let Some(target) = instance_coordinator::find_recent_instance() else { + return false; + }; + + let command = match action.action { + ContextAction::OpenRepo => instance_coordinator::CoordinatorCommand::OpenRepo { + path: action.path.clone(), + }, + ContextAction::CloneRepo => instance_coordinator::CoordinatorCommand::OpenCloneWindow { + options: CloneStartupOptions { + repo_url: action.repo_url.clone(), + destination: action.destination.clone().or_else(|| Some(action.path.clone())), + start_clone: action.start_clone, + }, + }, + ContextAction::InitialiseRepo => instance_coordinator::CoordinatorCommand::InitialiseRepo { + path: action.path.clone(), + }, + }; + + instance_coordinator::send_command(target.port, &command).is_ok() +} + fn detect_git_backend() -> GitBackend { #[cfg(target_os = "linux")] if std::env::var_os("FLATPAK_ID").is_some() { @@ -1260,9 +1289,9 @@ fn get_startup_action(state: tauri::State<'_, StartupState>) -> Option, -) -> Option { +fn take_pending_clone_options( + state: tauri::State<'_, PendingCloneOptions>, +) -> Option { state.0.lock().ok().and_then(|mut g| g.take()) } @@ -1273,19 +1302,27 @@ fn open_repo_in_new_window(path: String) -> Result<(), String> { #[tauri::command] async fn open_clone_window( + repo_url: Option, destination: Option, + start_clone: Option, app: tauri::AppHandle, state: tauri::State<'_, AppState>, - pending: tauri::State<'_, PendingCloneDestination>, + pending: tauri::State<'_, PendingCloneOptions>, ) -> Result<(), String> { + let options = CloneStartupOptions { + repo_url, + destination, + start_clone: start_clone.unwrap_or(false), + }; + if let Some(existing) = app.get_webview_window("clone-repository") { - if let Some(path) = destination { + if options.repo_url.is_some() || options.destination.is_some() || options.start_clone { let mut guard = pending .0 .lock() - .map_err(|_| "Internal clone destination state error".to_string())?; - *guard = Some(path.clone()); - let _ = app.emit("clone-destination-updated", path); + .map_err(|_| "Internal clone options state error".to_string())?; + *guard = Some(options.clone()); + let _ = app.emit("clone-options-updated", options); } let _ = existing.show(); let _ = existing.set_focus(); @@ -1296,7 +1333,7 @@ async fn open_clone_window( if instance_coordinator::send_command( owner.port, &instance_coordinator::CoordinatorCommand::OpenCloneWindow { - destination: destination.clone(), + options: options.clone(), }, ) .is_ok() @@ -1305,12 +1342,12 @@ async fn open_clone_window( } } - if let Some(path) = destination { + if options.repo_url.is_some() || options.destination.is_some() || options.start_clone { let mut guard = pending .0 .lock() - .map_err(|_| "Internal clone destination state error".to_string())?; - *guard = Some(path); + .map_err(|_| "Internal clone options state error".to_string())?; + *guard = Some(options); } window_manager::open_sub_window( @@ -1328,14 +1365,25 @@ async fn open_clone_window( } pub fn run() { + let startup_action = match shell::cli::parse_cli(std::env::args_os()) { + CliOutcome::Launch(action) => action, + CliOutcome::Print(output) => { + print!("{output}"); + return; + } + CliOutcome::Error(output) => { + eprint!("{output}"); + std::process::exit(2); + } + }; + let startup_action_for_setup = startup_action.clone(); + #[cfg(target_os = "linux")] sanitize_linux_xdg_env(); #[cfg(target_os = "linux")] apply_linux_appimage_webkit_workarounds(); - let startup_action = shell::cli::parse_shell_action(&std::env::args().collect::>()); - let builder = tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) @@ -1351,8 +1399,8 @@ pub fn run() { .manage(CloneCancelFlag(Arc::new(AtomicBool::new(false)))) .manage(FsWatcherState(Mutex::new(None))) .manage(StartupState(Mutex::new(startup_action))) - .manage(PendingCloneDestination(Mutex::new(None))) - .setup(|app| { + .manage(PendingCloneOptions(Mutex::new(None))) + .setup(move |app| { register_msix_application_restart(); #[cfg(windows)] @@ -1394,6 +1442,12 @@ pub fn run() { instance_coordinator::init(&app.handle())?; + if let Some(action) = startup_action_for_setup.as_ref() { + if forward_reuse_window_action(action) { + std::process::exit(0); + } + } + Ok(()) }) .on_window_event(|window, event| { @@ -1564,7 +1618,7 @@ pub fn run() { get_startup_action, open_clone_window, open_repo_in_new_window, - take_pending_clone_destination, + take_pending_clone_options, ]) .run(tauri::generate_context!()) .expect("failed to run tauri application"); diff --git a/src-tauri/src/shell/cli.rs b/src-tauri/src/shell/cli.rs index 7113879..670ed17 100644 --- a/src-tauri/src/shell/cli.rs +++ b/src-tauri/src/shell/cli.rs @@ -1,154 +1,382 @@ -use crate::shell::ContextAction; +use crate::shell::{ContextAction, WindowRouting}; +use clap::{CommandFactory, Parser, Subcommand, ValueEnum}; +use clap_complete::{Shell, generate}; use serde::{Deserialize, Serialize}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ShellStartupAction { pub action: ContextAction, pub path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub routing: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub repo_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub destination: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub start_clone: bool, } -pub fn parse_shell_action(args: &[String]) -> Option { - let mut iter = args.iter(); +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct CloneStartupOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub repo_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub destination: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub start_clone: bool, +} - iter.next()?; +fn is_false(value: &bool) -> bool { + !*value +} - while let Some(arg) = iter.next() { - match arg.as_str() { - "--open" => { - if let Some(path) = iter.next() { - return Some(ShellStartupAction { - action: ContextAction::OpenRepo, - path: path.clone(), - }); - } - } - "--clone-here" => { - if let Some(path) = iter.next() { - return Some(ShellStartupAction { - action: ContextAction::CloneHere, - path: path.clone(), - }); - } - } - s if s.starts_with("--open=") => { - if let Some(path) = s.strip_prefix("--open=") { - return Some(ShellStartupAction { - action: ContextAction::OpenRepo, - path: path.to_string(), - }); - } - } - s if s.starts_with("--clone-here=") => { - if let Some(path) = s.strip_prefix("--clone-here=") { - return Some(ShellStartupAction { - action: ContextAction::CloneHere, - path: path.to_string(), - }); - } +#[derive(Debug, PartialEq)] +pub enum CliOutcome { + Launch(Option), + Print(String), + Error(String), +} + +#[derive(Parser, Debug)] +#[command( + name = "gitmun", + version, + about = "Launch Gitmun from the command line", + disable_help_subcommand = true +)] +struct Cli { + #[arg(long, global = true, conflicts_with = "reuse_window")] + new_window: bool, + #[arg(long, global = true, conflicts_with = "new_window")] + reuse_window: bool, + #[arg(value_name = "PATH")] + path: Option, + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand, Debug)] +enum Command { + Open { + #[arg(value_name = "PATH")] + path: PathBuf, + }, + Clone { + #[arg(value_name = "REPO")] + repo: Option, + #[arg(value_name = "DESTINATION")] + destination: Option, + #[arg(long, value_name = "DESTINATION", conflicts_with = "destination")] + to: Option, + #[arg(long)] + start: bool, + }, + Init { + #[arg(value_name = "PATH")] + path: Option, + }, + #[command(hide = true)] + Initialise { + #[arg(value_name = "PATH")] + path: Option, + }, + Completions { + shell: CompletionShell, + }, +} + +#[derive(Clone, Debug, ValueEnum)] +enum CompletionShell { + Bash, + Zsh, + Fish, + Powershell, +} + +impl From for Shell { + fn from(value: CompletionShell) -> Self { + match value { + CompletionShell::Bash => Shell::Bash, + CompletionShell::Zsh => Shell::Zsh, + CompletionShell::Fish => Shell::Fish, + CompletionShell::Powershell => Shell::PowerShell, + } + } +} + +pub fn parse_cli(args: impl IntoIterator) -> CliOutcome { + let args = args.into_iter().collect::>(); + let cli = match Cli::try_parse_from(&args) { + Ok(cli) => cli, + Err(error) => { + let text = error.to_string(); + return if error.use_stderr() { + CliOutcome::Error(text) + } else { + CliOutcome::Print(text) + }; + } + }; + + let routing = routing_for(&cli); + match cli.command { + Some(Command::Open { path }) => launch_action(ContextAction::OpenRepo, path, routing), + Some(Command::Clone { + repo, + destination, + to, + start, + }) => clone_action(repo, destination.or(to), start, routing), + Some(Command::Init { path }) | Some(Command::Initialise { path }) => launch_action( + ContextAction::InitialiseRepo, + path.unwrap_or_else(current_dir_path), + routing, + ), + Some(Command::Completions { shell }) => completion_script(shell), + None => { + if let Some(path) = cli.path { + launch_action(ContextAction::OpenRepo, path, routing) + } else { + CliOutcome::Launch(None) } - _ => {} } } +} + +fn routing_for(cli: &Cli) -> Option { + if cli.new_window { + Some(WindowRouting::NewWindow) + } else if cli.reuse_window { + Some(WindowRouting::ReuseWindow) + } else { + None + } +} + +fn completion_script(shell: CompletionShell) -> CliOutcome { + let mut command = Cli::command(); + let mut output = Vec::new(); + generate(Shell::from(shell), &mut command, "gitmun", &mut output); + CliOutcome::Print(String::from_utf8_lossy(&output).into_owned()) +} - None +fn launch_action( + action: ContextAction, + path: PathBuf, + routing: Option, +) -> CliOutcome { + CliOutcome::Launch(Some(ShellStartupAction { + action, + path: normalise_cli_path(&path), + routing, + repo_url: None, + destination: None, + start_clone: false, + })) +} + +fn clone_action( + repo_url: Option, + destination: Option, + start_clone: bool, + routing: Option, +) -> CliOutcome { + let destination = destination.map(|path| normalise_cli_path(&path)); + let path = destination + .clone() + .unwrap_or_else(|| current_dir_path().to_string_lossy().into_owned()); + + CliOutcome::Launch(Some(ShellStartupAction { + action: ContextAction::CloneRepo, + path, + routing, + repo_url, + destination, + start_clone, + })) +} + +fn current_dir_path() -> PathBuf { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) +} + +fn normalise_cli_path(path: &Path) -> String { + let resolved = if path.is_absolute() { + path.to_path_buf() + } else { + current_dir_path().join(path) + }; + resolved.to_string_lossy().into_owned() } #[cfg(test)] mod tests { use super::*; + fn parse(args: &[&str]) -> CliOutcome { + parse_cli(args.iter().map(OsString::from)) + } + + fn cwd_path(path: &str) -> String { + current_dir_path().join(path).to_string_lossy().into_owned() + } + #[test] - fn test_parse_open_short() { - let args = vec![ - "/usr/bin/gitmun".to_string(), - "--open".to_string(), - "/home/user/project".to_string(), - ]; - let result = parse_shell_action(&args); + fn parses_bare_launch() { + assert_eq!(parse(&["gitmun"]), CliOutcome::Launch(None)); + } + + #[test] + fn parses_positional_path_as_open_repo() { assert_eq!( - result, - Some(ShellStartupAction { + parse(&["gitmun", "."]), + CliOutcome::Launch(Some(ShellStartupAction { + action: ContextAction::OpenRepo, + path: cwd_path("."), + routing: None, + repo_url: None, + destination: None, + start_clone: false, + })) + ); + } + + #[test] + fn parses_open_command() { + assert_eq!( + parse(&["gitmun", "open", "/home/user/project"]), + CliOutcome::Launch(Some(ShellStartupAction { action: ContextAction::OpenRepo, path: "/home/user/project".to_string(), - }) + routing: None, + repo_url: None, + destination: None, + start_clone: false, + })) ); } #[test] - fn test_parse_clone_here_short() { - let args = vec![ - "/usr/bin/gitmun".to_string(), - "--clone-here".to_string(), - "/home/user/dir".to_string(), - ]; - let result = parse_shell_action(&args); + fn parses_clone_defaulting_to_current_dir() { assert_eq!( - result, - Some(ShellStartupAction { - action: ContextAction::CloneHere, - path: "/home/user/dir".to_string(), - }) + parse(&["gitmun", "clone"]), + CliOutcome::Launch(Some(ShellStartupAction { + action: ContextAction::CloneRepo, + path: current_dir_path().to_string_lossy().into_owned(), + routing: None, + repo_url: None, + destination: None, + start_clone: false, + })) ); } #[test] - fn test_parse_open_equals() { - let args = vec![ - "/usr/bin/gitmun".to_string(), - "--open=/home/user/project".to_string(), - ]; - let result = parse_shell_action(&args); + fn parses_clone_repo_and_destination() { assert_eq!( - result, - Some(ShellStartupAction { - action: ContextAction::OpenRepo, - path: "/home/user/project".to_string(), - }) + parse(&[ + "gitmun", + "clone", + "git@github.com:owner/repo.git", + "/home/user/projects/repo" + ]), + CliOutcome::Launch(Some(ShellStartupAction { + action: ContextAction::CloneRepo, + path: "/home/user/projects/repo".to_string(), + routing: None, + repo_url: Some("git@github.com:owner/repo.git".to_string()), + destination: Some("/home/user/projects/repo".to_string()), + start_clone: false, + })) ); } #[test] - fn test_parse_no_args() { - let args = vec!["/usr/bin/gitmun".to_string()]; - let result = parse_shell_action(&args); - assert_eq!(result, None); + fn parses_clone_destination_option() { + assert_eq!( + parse(&["gitmun", "clone", "--to", "/home/user/projects"]), + CliOutcome::Launch(Some(ShellStartupAction { + action: ContextAction::CloneRepo, + path: "/home/user/projects".to_string(), + routing: None, + repo_url: None, + destination: Some("/home/user/projects".to_string()), + start_clone: false, + })) + ); } #[test] - fn test_parse_unknown_flag() { - let args = vec![ - "/usr/bin/gitmun".to_string(), - "--unknown".to_string(), - ]; - let result = parse_shell_action(&args); - assert_eq!(result, None); + fn parses_clone_start_flag() { + assert_eq!( + parse(&["gitmun", "clone", "https://example.test/repo.git", "--start"]), + CliOutcome::Launch(Some(ShellStartupAction { + action: ContextAction::CloneRepo, + path: current_dir_path().to_string_lossy().into_owned(), + routing: None, + repo_url: Some("https://example.test/repo.git".to_string()), + destination: None, + start_clone: true, + })) + ); } #[test] - fn test_parse_open_without_path() { - let args = vec![ - "/usr/bin/gitmun".to_string(), - "--open".to_string(), - ]; - let result = parse_shell_action(&args); - assert_eq!(result, None); + fn parses_init_defaulting_to_current_dir() { + assert_eq!( + parse(&["gitmun", "init"]), + CliOutcome::Launch(Some(ShellStartupAction { + action: ContextAction::InitialiseRepo, + path: current_dir_path().to_string_lossy().into_owned(), + routing: None, + repo_url: None, + destination: None, + start_clone: false, + })) + ); } #[test] - fn test_parse_windows_path() { - let args = vec![ - r"C:\Program Files\Gitmun\gitmun.exe".to_string(), - "--open".to_string(), - r"D:\Projects\my-repo".to_string(), - ]; - let result = parse_shell_action(&args); + fn parses_window_routing() { assert_eq!( - result, - Some(ShellStartupAction { + parse(&["gitmun", "--reuse-window", "open", "."]), + CliOutcome::Launch(Some(ShellStartupAction { action: ContextAction::OpenRepo, - path: r"D:\Projects\my-repo".to_string(), - }) + path: cwd_path("."), + routing: Some(WindowRouting::ReuseWindow), + repo_url: None, + destination: None, + start_clone: false, + })) ); } -} \ No newline at end of file + + #[test] + fn help_prints_without_launching() { + match parse(&["gitmun", "--help"]) { + CliOutcome::Print(text) => assert!(text.contains("Usage:")), + other => panic!("expected print outcome, got {other:?}"), + } + } + + #[test] + fn completions_print_without_launching() { + match parse(&["gitmun", "completions", "bash"]) { + CliOutcome::Print(text) => assert!(text.contains("gitmun")), + other => panic!("expected print outcome, got {other:?}"), + } + } + + #[test] + fn unknown_flag_errors_without_launching() { + match parse(&["gitmun", "--unknown"]) { + CliOutcome::Error(text) => assert!(text.contains("--unknown")), + other => panic!("expected error outcome, got {other:?}"), + } + } +} diff --git a/src-tauri/src/shell/mod.rs b/src-tauri/src/shell/mod.rs index 880ac76..2b70873 100644 --- a/src-tauri/src/shell/mod.rs +++ b/src-tauri/src/shell/mod.rs @@ -6,5 +6,13 @@ use serde::{Deserialize, Serialize}; #[serde(rename_all = "camelCase")] pub enum ContextAction { OpenRepo, - CloneHere, + CloneRepo, + InitialiseRepo, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum WindowRouting { + NewWindow, + ReuseWindow, } diff --git a/src/api/commands.ts b/src/api/commands.ts index 82ce5d3..372ab63 100644 --- a/src/api/commands.ts +++ b/src/api/commands.ts @@ -67,6 +67,7 @@ import type { SetRemoteUrlRequest, PruneRemoteRequest, StashEntry, + CloneStartupOptions, ShellStartupAction, } from "../types"; @@ -579,7 +580,7 @@ export function openSettingsWindow(): Promise { } export function openCloneWindow(): Promise { - return invoke("open_clone_window", {destination: null}); + return openCloneWindowWithOptions(); } export function openAboutWindow(): Promise { @@ -626,10 +627,14 @@ export function openRepoInNewWindow(path: string): Promise { return invoke("open_repo_in_new_window", {path}); } -export function openCloneWindowWithDestination(destination?: string): Promise { - return invoke("open_clone_window", {destination: destination ?? null}); +export function openCloneWindowWithOptions(options: CloneStartupOptions = {}): Promise { + return invoke("open_clone_window", { + repoUrl: options.repoUrl ?? null, + destination: options.destination ?? null, + startClone: options.startClone ?? false, + }); } -export function takePendingCloneDestination(): Promise { - return invoke("take_pending_clone_destination"); +export function takePendingCloneOptions(): Promise { + return invoke("take_pending_clone_options"); } diff --git a/src/components/App.tsx b/src/components/App.tsx index d166483..67b67d6 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -240,11 +240,26 @@ export function App() { showToast(String(e), "error"); } }); - } else if (action.action === "cloneHere") { - api.openCloneWindowWithDestination(action.path).catch(e => { + } else if (action.action === "cloneRepo") { + api.openCloneWindowWithOptions({ + repoUrl: action.repoUrl, + destination: action.destination ?? action.path, + startClone: action.startClone, + }).catch(e => { showToast(String(e), "error"); appendResultLog("error", t("log.cloneWindowFailed", {message: String(e)}), "unknown"); }); + } else if (action.action === "initialiseRepo") { + api.initRepo(action.path).then(async (result) => { + await api.validateRepoPath(action.path); + setRepoPath(action.path); + pushRecentRepo(action.path); + showToast(t("toast.repositoryInitialised"), "success"); + appendResultLog("success", result.message, result.backendUsed, action.path); + }).catch(e => { + showToast(String(e), "error"); + appendResultLog("error", String(e), "unknown", action.path); + }); } }, [pushRecentRepo, showToast, t]); @@ -254,6 +269,23 @@ export function App() { }).catch(() => {}); }, [handleShellAction]); + useEffect(() => { + let cancelled = false; + let unlisten: (() => void) | null = null; + (async () => { + const fn = await listen("instance-initialise-repo", (event) => { + const path = event.payload; + if (!path) return; + handleShellAction({action: "initialiseRepo", path}); + }); + if (cancelled) fn(); else unlisten = fn; + })(); + return () => { + cancelled = true; + unlisten?.(); + }; + }, [handleShellAction]); + useEffect(() => { let cancelled = false; let unlisten: (() => void) | null = null; @@ -441,10 +473,10 @@ export function App() { }, [showToast, t]); const handleCloneClick = useCallback(() => { - api.openCloneWindow().catch(e => { - showToast(String(e), "error"); - appendResultLog("error", t("log.cloneWindowFailed", {message: String(e)}), "unknown"); - }); + api.openCloneWindow().catch(e => { + showToast(String(e), "error"); + appendResultLog("error", t("log.cloneWindowFailed", {message: String(e)}), "unknown"); + }); }, [showToast, t]); const maybeInitialiseRepo = useCallback(async (path: string, error: unknown): Promise => { diff --git a/src/components/clone/CloneWindow.tsx b/src/components/clone/CloneWindow.tsx index 5c4d8b2..18ef37c 100644 --- a/src/components/clone/CloneWindow.tsx +++ b/src/components/clone/CloneWindow.tsx @@ -5,11 +5,11 @@ import { open } from "@tauri-apps/plugin-dialog"; import { platform } from "@tauri-apps/plugin-os"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { useTranslation } from "react-i18next"; -import type { OperationResult, Settings } from "../../types"; +import type { CloneStartupOptions, OperationResult, Settings } from "../../types"; import { CloseIcon, FolderIcon } from "../icons"; import { appendResultLog } from "../../utils/resultLog"; import { getCloneRepoUrlError } from "../../utils/gitInputValidation"; -import { takePendingCloneDestination } from "../../api/commands"; +import { takePendingCloneOptions } from "../../api/commands"; import { applyThemeMode } from "../../utils/theme"; import { applyUiTextScale } from "../../utils/uiTextScale"; import "./CloneWindow.css"; @@ -36,6 +36,14 @@ function getBaseDir(path: string): string { return lastSep > 0 ? path.slice(0, lastSep) : path; } +function destinationForRepo(base: string, repoUrl: string): string { + const name = parseRepoName(repoUrl); + if (!name) return base; + if (!base) return name; + const sep = base.includes("\\") ? "\\" : "/"; + return base + sep + name; +} + export function CloneWindow() { const { t } = useTranslation("clone"); const useNativeWindowBar = true; @@ -60,6 +68,78 @@ export function CloneWindow() { ? t("placeholders.destinationMac") : t("placeholders.destinationLinux"); + const cloneWithValues = useCallback(async (repoUrlValue: string, destinationValue: string) => { + if (!repoUrlValue.trim()) { + setStatus(t("log.repoUrlRequired")); + return; + } + const inputError = getCloneRepoUrlError(repoUrlValue); + if (inputError) { + setStatus(t(inputError, { ns: "git", defaultValue: inputError })); + return; + } + setCloning(true); + setStatus(t("log.cloning")); + setProgressLines([]); + + const onProgress = new Channel(); + onProgress.onmessage = line => { + setProgressLines(prev => [...prev.slice(-99), line]); + }; + + try { + const result = await invoke("clone_repo", { + request: { repoUrl: repoUrlValue, destination: destinationValue }, + onProgress, + }); + + // Persist the base dir (parent of what was cloned into) for next time. + const lastSep = Math.max(destinationValue.lastIndexOf("/"), destinationValue.lastIndexOf("\\")); + if (lastSep > 0) { + localStorage.setItem(CLONE_BASE_KEY, destinationValue.slice(0, lastSep)); + } + + if (result.repoPath) { + await emit("repository-selected", { repoPath: result.repoPath }); + } + + const outputDetails = result.output ? ` (${result.output})` : ""; + setStatus(`${result.message}${outputDetails}`); + appendResultLog("success", result.message, result.backendUsed, result.repoPath ?? destinationValue); + await getCurrentWindow().close(); + } catch (e) { + const msg = String(e); + if (msg.includes("cancelled")) { + setStatus(t("log.cloneCancelled")); + } else { + const message = t("log.cloneFailed", { message: msg }); + setStatus(message); + appendResultLog("error", message, "unknown", destinationValue); + } + } finally { + setCloning(false); + } + }, [t]); + + const applyCloneOptions = useCallback((options: CloneStartupOptions, fallbackDestination: string) => { + const nextRepoUrl = options.repoUrl ?? ""; + const nextDestination = options.destination + ?? (nextRepoUrl ? destinationForRepo(fallbackDestination, nextRepoUrl) : fallbackDestination); + + if (options.repoUrl != null) { + setRepoUrl(options.repoUrl); + } + if (nextDestination) { + baseDirRef.current = options.destination ?? fallbackDestination; + setDestination(nextDestination); + isAutoRef.current = options.destination == null; + } + + if (options.startClone) { + void cloneWithValues(nextRepoUrl, nextDestination); + } + }, [cloneWithValues]); + useEffect(() => { (async () => { try { @@ -71,42 +151,27 @@ export function CloneWindow() { await applyThemeMode(settings.themeMode); applyUiTextScale(settings.uiTextScale); - // Initialise destination: pending shell destination > last-used dir > settings default > OS default. - const pendingDestination = await takePendingCloneDestination(); - if (pendingDestination) { - baseDirRef.current = pendingDestination; - setDestination(pendingDestination); - isAutoRef.current = false; - } else { - const lastUsed = localStorage.getItem(CLONE_BASE_KEY); - if (lastUsed) { - baseDirRef.current = lastUsed; - setDestination(lastUsed); - } else if (settings.defaultCloneDir) { - baseDirRef.current = settings.defaultCloneDir; - setDestination(settings.defaultCloneDir); - } else { - const dir = await invoke("get_default_clone_dir"); - baseDirRef.current = dir; - setDestination(dir); - } + const lastUsed = localStorage.getItem(CLONE_BASE_KEY); + const fallbackDestination = lastUsed || settings.defaultCloneDir || await invoke("get_default_clone_dir"); + baseDirRef.current = fallbackDestination; + setDestination(fallbackDestination); + + const pendingOptions = await takePendingCloneOptions(); + if (pendingOptions) { + applyCloneOptions(pendingOptions, fallbackDestination); } } catch (e) { setStatus(t("log.loadFailed", { message: String(e) })); } })(); - }, [t]); + }, [applyCloneOptions, t]); useEffect(() => { let cancelled = false; let unlisten: (() => void) | null = null; (async () => { - const fn = await listen("clone-destination-updated", (event) => { - const path = event.payload; - if (!path) return; - baseDirRef.current = path; - setDestination(path); - isAutoRef.current = false; + const fn = await listen("clone-options-updated", (event) => { + applyCloneOptions(event.payload, destination || baseDirRef.current); }); if (cancelled) fn(); else unlisten = fn; })(); @@ -114,7 +179,7 @@ export function CloneWindow() { cancelled = true; unlisten?.(); }; - }, []); + }, [applyCloneOptions, destination]); useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -145,8 +210,7 @@ export function CloneWindow() { setDestination(name); return; } - const sep = base.includes("\\") ? "\\" : "/"; - setDestination(base + sep + name); + setDestination(destinationForRepo(base, name)); }, [repoUrl]); const handleRepoUrlChange = useCallback((val: string) => { @@ -169,8 +233,7 @@ export function CloneWindow() { }); if (typeof selected === "string") { const repoName = parseRepoName(repoUrl); - const sep = selected.includes("\\") ? "\\" : "/"; - const newDest = repoName ? selected + sep + repoName : selected; + const newDest = repoName ? destinationForRepo(selected, repoUrl) : selected; baseDirRef.current = selected; setDestination(newDest); isAutoRef.current = true; @@ -183,57 +246,8 @@ export function CloneWindow() { }, [destination, repoUrl, t]); const handleClone = useCallback(async () => { - if (!repoUrl.trim()) { - setStatus(t("log.repoUrlRequired")); - return; - } - const inputError = getCloneRepoUrlError(repoUrl); - if (inputError) { - setStatus(t(inputError, { ns: "git", defaultValue: inputError })); - return; - } - setCloning(true); - setStatus(t("log.cloning")); - setProgressLines([]); - - const onProgress = new Channel(); - onProgress.onmessage = line => { - setProgressLines(prev => [...prev.slice(-99), line]); - }; - - try { - const result = await invoke("clone_repo", { - request: { repoUrl, destination }, - onProgress, - }); - - // Persist the base dir (parent of what was cloned into) for next time. - const lastSep = Math.max(destination.lastIndexOf("/"), destination.lastIndexOf("\\")); - if (lastSep > 0) { - localStorage.setItem(CLONE_BASE_KEY, destination.slice(0, lastSep)); - } - - if (result.repoPath) { - await emit("repository-selected", { repoPath: result.repoPath }); - } - - const outputDetails = result.output ? ` (${result.output})` : ""; - setStatus(`${result.message}${outputDetails}`); - appendResultLog("success", result.message, result.backendUsed, result.repoPath ?? destination); - await getCurrentWindow().close(); - } catch (e) { - const msg = String(e); - if (msg.includes("cancelled")) { - setStatus(t("log.cloneCancelled")); - } else { - const message = t("log.cloneFailed", { message: msg }); - setStatus(message); - appendResultLog("error", message, "unknown", destination); - } - } finally { - setCloning(false); - } - }, [repoUrl, destination, t]); + await cloneWithValues(repoUrl, destination); + }, [cloneWithValues, destination, repoUrl]); const handleCancel = useCallback(async () => { await invoke("cancel_clone"); diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 54d2813..7fedf8b 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -71,7 +71,7 @@ "lineEndings": "Changing line ending conversion can make many files appear modified until the repository is renormalised.", "linuxGraphics": "Takes effect on next launch. Use \"Maximum compatibility\" if you see rendering crashes or a blank window.", "commitMessageRecommendedLength": "Recommended maximum for the first line of a commit message. Set to 0 to disable.", - "repoOpenBehaviour": "Controls in-app repository opens, including recent repositories. Shell and file manager launches always open a new window.", + "repoOpenBehaviour": "Controls in-app repository opens, including recent repositories. Shell and file manager launches open a new window unless the launcher asks to reuse one.", "rowStriping": "Alternates list row backgrounds in changes and commit views for easier scanning.", "toolPathMissing": "{{tool}} could not be found automatically. Saving will fail until you choose its executable.", "toolPathSearch": "Gitmun searches PATH first, then common Windows install folders. If that fails, choose the executable manually.", diff --git a/src/types.ts b/src/types.ts index accf57a..8f4ffe5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -683,11 +683,22 @@ export type RemoteInfo = { url: string; }; -export type ContextAction = "openRepo" | "cloneHere"; +export type ContextAction = "openRepo" | "cloneRepo" | "initialiseRepo"; +export type WindowRouting = "newWindow" | "reuseWindow"; export type ShellStartupAction = { action: ContextAction; path: string; + routing?: WindowRouting; + repoUrl?: string; + destination?: string; + startClone?: boolean; +}; + +export type CloneStartupOptions = { + repoUrl?: string; + destination?: string; + startClone?: boolean; }; export type RepositorySelectedPayload = { From 38ea73c1c556c71f53ab5ec1bcf4c997e12d99c8 Mon Sep 17 00:00:00 2001 From: cst8t <1810150+cst8t@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:31:23 +0100 Subject: [PATCH 2/5] feat: add Linux terminal preferences --- src-tauri/Cargo.lock | 6 + src-tauri/Cargo.toml | 1 + src-tauri/src/commands/repo.rs | 145 +++++++++------- src-tauri/src/commands/settings.rs | 97 ++++++++++- src-tauri/src/git/handler.rs | 15 ++ src-tauri/src/git/types.rs | 30 ++++ src-tauri/src/lib.rs | 8 +- src/api/commands.ts | 14 ++ .../settings/SettingsWindow.test.tsx | 156 ++++++++++++++++++ src/components/settings/SettingsWindow.tsx | 66 +++++++- src/i18n/locales/en/settings.json | 11 +- src/types.ts | 21 +++ 12 files changed, 502 insertions(+), 68 deletions(-) create mode 100644 src/components/settings/SettingsWindow.test.tsx diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 325e5c5..9122d05 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1608,6 +1608,7 @@ dependencies = [ "gix", "gtk", "infer", + "linux-terminal-launch", "md5", "mime_guess", "notify", @@ -3439,6 +3440,11 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "linux-terminal-launch" +version = "0.1.0" +source = "git+https://github.com/cst8t/linux-terminal-launch#ef3a29828f15816a9c1f6f8244e018a96d0c3872" + [[package]] name = "litemap" version = "0.8.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c18d798..51be5bb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -41,6 +41,7 @@ tempfile = "3" [target.'cfg(target_os = "linux")'.dependencies] gtk = { version = "0.18", features = ["v3_24"] } +linux-terminal-launch = { git = "https://github.com/cst8t/linux-terminal-launch" } pci-info = { version = "0.3.4", default-features = false } webkit2gtk = "2.0.2" diff --git a/src-tauri/src/commands/repo.rs b/src-tauri/src/commands/repo.rs index 29753d5..8fc1b4c 100644 --- a/src-tauri/src/commands/repo.rs +++ b/src-tauri/src/commands/repo.rs @@ -2,15 +2,16 @@ use crate::git::types::{ CloneRequest, CommitDetails, CommitDetailsRequest, CommitFileItem, CommitFilesRequest, CommitMarkers, CommitRequest, DiffRequest, ExportPatchRequest, ExternalDiffRequest, FetchRequest, FileDiff, FileRequest, GitIdentity, HunkStageRequest, IdentityRequest, - ImportPatchRequest, NumstatRequest, NumstatResult, OperationResult, PullAnalysis, - PullStrategyRequest, PushRequest, PushResult, RepoRequest, RepoStatus, SetIdentityRequest, - StageFilesRequest, StashEntry, StashPushRequest, StashRequest, SubmoduleActionRequest, + ImportPatchRequest, LinuxTerminalEmulator, NumstatRequest, NumstatResult, OperationResult, + PullAnalysis, PullStrategyRequest, PushRequest, PushResult, RepoRequest, RepoStatus, + SetIdentityRequest, StageFilesRequest, StashEntry, StashPushRequest, StashRequest, + SubmoduleActionRequest, }; use crate::{AppState, CloneCancelFlag, configure_command}; use serde::{Deserialize, Serialize}; use std::io::Read; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; +use std::process::Stdio; use std::sync::atomic::Ordering; use tauri::Manager; use tauri_plugin_opener::OpenerExt; @@ -33,7 +34,8 @@ pub struct RepoOpenLocation { } #[tauri::command] -pub fn get_repo_open_locations() -> Vec { +pub fn get_repo_open_locations(state: tauri::State<'_, AppState>) -> Vec { + let terminal_label = default_terminal_label(&state.git_service.get_settings()); let locations = vec![ RepoOpenLocation { kind: RepoOpenLocationKind::FileExplorer, @@ -43,7 +45,7 @@ pub fn get_repo_open_locations() -> Vec { }, RepoOpenLocation { kind: RepoOpenLocationKind::Terminal, - label: default_terminal_label().to_string(), + label: terminal_label, fallback_label: "Terminal".to_string(), icon_data_url: None, }, @@ -71,6 +73,7 @@ pub fn open_repo_location( repo_path: String, kind: RepoOpenLocationKind, app: tauri::AppHandle, + state: tauri::State<'_, AppState>, ) -> Result { let path = validate_repo_open_path(&repo_path)?; @@ -85,7 +88,7 @@ pub fn open_repo_location( )) } RepoOpenLocationKind::Terminal => { - open_terminal_at(&path)?; + open_terminal_at(&path, &state.git_service.get_settings())?; Ok(repo_open_result( "Opened repository in Terminal".to_string(), path, @@ -143,29 +146,41 @@ fn default_file_manager_label() -> &'static str { } } -fn default_terminal_label() -> &'static str { - "Terminal" +fn default_terminal_label(settings: &crate::git::types::Settings) -> String { + #[cfg(target_os = "linux")] + { + return linux_terminal_label(settings).to_string(); + } + + #[cfg(not(target_os = "linux"))] + { + let _ = settings; + "Terminal".to_string() + } } -fn open_terminal_at(path: &Path) -> Result<(), String> { +fn open_terminal_at(path: &Path, settings: &crate::git::types::Settings) -> Result<(), String> { #[cfg(target_os = "windows")] { + let _ = settings; return open_terminal_at_windows(path); } #[cfg(target_os = "macos")] { + let _ = settings; return open_terminal_at_macos(path); } #[cfg(target_os = "linux")] { - return open_terminal_at_linux(path); + return open_terminal_at_linux(path, settings); } #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] { let _ = path; + let _ = settings; Err("Opening a terminal is not supported on this platform".to_string()) } } @@ -187,7 +202,7 @@ fn open_git_bash_at(path: &Path) -> Result<(), String> { fn open_git_bash_at_windows(path: &Path) -> Result<(), String> { let git_bash = crate::resolve_system_git_bash_exe() .ok_or_else(|| "Git Bash from Git for Windows was not found".to_string())?; - Command::new(git_bash) + std::process::Command::new(git_bash) .arg(format!("--cd={}", path.display())) .spawn() .map(|_| ()) @@ -196,13 +211,13 @@ fn open_git_bash_at_windows(path: &Path) -> Result<(), String> { #[cfg(target_os = "windows")] fn open_terminal_at_windows(path: &Path) -> Result<(), String> { - let mut wt = Command::new("wt.exe"); + let mut wt = std::process::Command::new("wt.exe"); wt.arg("-d").arg(path); if wt.spawn().is_ok() { return Ok(()); } - Command::new("cmd.exe") + std::process::Command::new("cmd.exe") .arg("/C") .arg("start") .arg("") @@ -216,7 +231,7 @@ fn open_terminal_at_windows(path: &Path) -> Result<(), String> { #[cfg(target_os = "macos")] fn open_terminal_at_macos(path: &Path) -> Result<(), String> { - Command::new("open") + std::process::Command::new("open") .arg("-a") .arg("Terminal") .arg(path) @@ -226,61 +241,63 @@ fn open_terminal_at_macos(path: &Path) -> Result<(), String> { } #[cfg(target_os = "linux")] -fn open_terminal_at_linux(path: &Path) -> Result<(), String> { - let mut errors = Vec::new(); - - if let Some(terminal) = std::env::var_os("TERMINAL").and_then(|value| value.into_string().ok()) - { - if !terminal.trim().is_empty() && spawn_terminal_command(&terminal, path, &mut errors) { - return Ok(()); - } - } - - for command in [ - "x-terminal-emulator", - "gnome-terminal", - "kgx", - "konsole", - "xfce4-terminal", - "mate-terminal", - "lxterminal", - "alacritty", - "kitty", - "wezterm", - "foot", - "xterm", - ] { - if spawn_terminal_command(command, path, &mut errors) { - return Ok(()); - } - } - - Err(if errors.is_empty() { - "No supported terminal emulator was found".to_string() - } else { - format!( - "No supported terminal emulator was found ({})", - errors.join("; ") - ) - }) +fn open_terminal_at_linux( + path: &Path, + settings: &crate::git::types::Settings, +) -> Result<(), String> { + linux_terminal_launcher(settings, path) + .spawn() + .map_err(|error| error.to_string()) } #[cfg(target_os = "linux")] -fn spawn_terminal_command(command_spec: &str, path: &Path, errors: &mut Vec) -> bool { - let mut parts = command_spec.split_whitespace(); - let Some(command_name) = parts.next() else { - return false; - }; +fn linux_terminal_label(settings: &crate::git::types::Settings) -> &'static str { + linux_terminal_launch::terminal_label(linux_terminal_preference( + settings.linux_terminal_emulator, + )) +} - let mut command = Command::new(command_name); - command.args(parts).current_dir(path); +#[cfg(target_os = "linux")] +fn linux_terminal_launcher( + settings: &crate::git::types::Settings, + path: &Path, +) -> linux_terminal_launch::TerminalLauncher { + linux_terminal_launch::TerminalLauncher::new() + .working_dir(path) + .preference(linux_terminal_preference(settings.linux_terminal_emulator)) + .custom_command(settings.linux_terminal_custom_command.clone()) + .detach_from_parent(true) +} - match command.spawn() { - Ok(_) => true, - Err(error) => { - errors.push(format!("{command_name}: {error}")); - false +#[cfg(target_os = "linux")] +fn linux_terminal_preference( + emulator: LinuxTerminalEmulator, +) -> linux_terminal_launch::TerminalPreference { + use linux_terminal_launch::{KnownTerminal, TerminalPreference}; + + match emulator { + LinuxTerminalEmulator::Auto => TerminalPreference::Auto, + LinuxTerminalEmulator::Konsole => TerminalPreference::Known(KnownTerminal::Konsole), + LinuxTerminalEmulator::GnomeTerminal => { + TerminalPreference::Known(KnownTerminal::GnomeTerminal) + } + LinuxTerminalEmulator::GnomeConsole => { + TerminalPreference::Known(KnownTerminal::GnomeConsole) + } + LinuxTerminalEmulator::Xfce4Terminal => { + TerminalPreference::Known(KnownTerminal::Xfce4Terminal) + } + LinuxTerminalEmulator::MateTerminal => { + TerminalPreference::Known(KnownTerminal::MateTerminal) } + LinuxTerminalEmulator::Lxterminal => TerminalPreference::Known(KnownTerminal::Lxterminal), + LinuxTerminalEmulator::Alacritty => TerminalPreference::Known(KnownTerminal::Alacritty), + LinuxTerminalEmulator::Ghostty => TerminalPreference::Known(KnownTerminal::Ghostty), + LinuxTerminalEmulator::Kitty => TerminalPreference::Known(KnownTerminal::Kitty), + LinuxTerminalEmulator::WezTerm => TerminalPreference::Known(KnownTerminal::WezTerm), + LinuxTerminalEmulator::Foot => TerminalPreference::Known(KnownTerminal::Foot), + LinuxTerminalEmulator::Xterm => TerminalPreference::Known(KnownTerminal::Xterm), + LinuxTerminalEmulator::Custom => TerminalPreference::Custom, } } diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 1ba961b..2991ba8 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -1,6 +1,7 @@ use crate::git::types::{ AvatarProviderMode, BackendMode, CommitDateMode, CommitPrimaryAction, ExternalDiffTool, - LinuxGraphicsMode, OperationResult, RepoOpenBehaviour, RowStriping, Settings, ThemeMode, + LinuxGraphicsMode, LinuxTerminalEmulator, OperationResult, RepoOpenBehaviour, RowStriping, + Settings, ThemeMode, }; use crate::{AppState, configure_command, git_command}; use reqwest::header::{ACCEPT, HeaderValue, RANGE}; @@ -1092,6 +1093,100 @@ pub fn set_linux_graphics_mode( state.git_service.set_linux_graphics_mode(mode) } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LinuxTerminalOption { + pub emulator: LinuxTerminalEmulator, + pub label: &'static str, +} + +#[tauri::command] +pub fn get_linux_terminal_options() -> Vec { + linux_terminal_options() +} + +#[cfg(target_os = "linux")] +fn linux_terminal_options() -> Vec { + let mut options = vec![LinuxTerminalOption { + emulator: LinuxTerminalEmulator::Auto, + label: linux_terminal_launch::terminal_label( + linux_terminal_launch::TerminalPreference::Auto, + ), + }]; + + options.extend( + linux_terminal_launch::known_terminals() + .iter() + .map(|terminal| LinuxTerminalOption { + emulator: linux_terminal_emulator(terminal.terminal), + label: terminal.label, + }), + ); + + options.push(LinuxTerminalOption { + emulator: LinuxTerminalEmulator::Custom, + label: linux_terminal_launch::terminal_label( + linux_terminal_launch::TerminalPreference::Custom, + ), + }); + + options +} + +#[cfg(target_os = "linux")] +fn linux_terminal_emulator( + terminal: linux_terminal_launch::KnownTerminal, +) -> LinuxTerminalEmulator { + match terminal { + linux_terminal_launch::KnownTerminal::Konsole => LinuxTerminalEmulator::Konsole, + linux_terminal_launch::KnownTerminal::GnomeTerminal => LinuxTerminalEmulator::GnomeTerminal, + linux_terminal_launch::KnownTerminal::GnomeConsole => LinuxTerminalEmulator::GnomeConsole, + linux_terminal_launch::KnownTerminal::Xfce4Terminal => LinuxTerminalEmulator::Xfce4Terminal, + linux_terminal_launch::KnownTerminal::MateTerminal => LinuxTerminalEmulator::MateTerminal, + linux_terminal_launch::KnownTerminal::Lxterminal => LinuxTerminalEmulator::Lxterminal, + linux_terminal_launch::KnownTerminal::Alacritty => LinuxTerminalEmulator::Alacritty, + linux_terminal_launch::KnownTerminal::Ghostty => LinuxTerminalEmulator::Ghostty, + linux_terminal_launch::KnownTerminal::Kitty => LinuxTerminalEmulator::Kitty, + linux_terminal_launch::KnownTerminal::WezTerm => LinuxTerminalEmulator::WezTerm, + linux_terminal_launch::KnownTerminal::Foot => LinuxTerminalEmulator::Foot, + linux_terminal_launch::KnownTerminal::Xterm => LinuxTerminalEmulator::Xterm, + } +} + +#[cfg(not(target_os = "linux"))] +fn linux_terminal_options() -> Vec { + vec![ + LinuxTerminalOption { + emulator: LinuxTerminalEmulator::Auto, + label: "Terminal", + }, + LinuxTerminalOption { + emulator: LinuxTerminalEmulator::Custom, + label: "Terminal", + }, + ] +} + +#[tauri::command] +pub fn set_linux_terminal_emulator( + linux_terminal_emulator: LinuxTerminalEmulator, + state: tauri::State<'_, AppState>, +) -> Settings { + state + .git_service + .set_linux_terminal_emulator(linux_terminal_emulator) +} + +#[tauri::command] +pub fn set_linux_terminal_custom_command( + linux_terminal_custom_command: String, + state: tauri::State<'_, AppState>, +) -> Settings { + state + .git_service + .set_linux_terminal_custom_command(linux_terminal_custom_command) +} + #[tauri::command] pub fn set_repo_open_behaviour( repo_open_behaviour: RepoOpenBehaviour, diff --git a/src-tauri/src/git/handler.rs b/src-tauri/src/git/handler.rs index a4f1978..b29ce51 100644 --- a/src-tauri/src/git/handler.rs +++ b/src-tauri/src/git/handler.rs @@ -316,6 +316,21 @@ impl GitService { }) } + pub fn set_linux_terminal_emulator( + &self, + emulator: super::types::LinuxTerminalEmulator, + ) -> Settings { + self.update_settings(|settings| { + settings.linux_terminal_emulator = emulator; + }) + } + + pub fn set_linux_terminal_custom_command(&self, command: String) -> Settings { + self.update_settings(|settings| { + settings.linux_terminal_custom_command = command; + }) + } + pub fn set_repo_open_behaviour(&self, behaviour: super::types::RepoOpenBehaviour) -> Settings { self.update_settings(|settings| { settings.repo_open_behaviour = behaviour; diff --git a/src-tauri/src/git/types.rs b/src-tauri/src/git/types.rs index c32182f..8dcb4fe 100644 --- a/src-tauri/src/git/types.rs +++ b/src-tauri/src/git/types.rs @@ -112,6 +112,30 @@ impl Default for LinuxGraphicsMode { } } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum LinuxTerminalEmulator { + Auto, + Konsole, + GnomeTerminal, + GnomeConsole, + Xfce4Terminal, + MateTerminal, + Lxterminal, + Alacritty, + Ghostty, + Kitty, + WezTerm, + Foot, + Xterm, + Custom, +} + +impl Default for LinuxTerminalEmulator { + fn default() -> Self { + Self::Auto + } +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub enum RepoOpenBehaviour { Ask, @@ -231,6 +255,10 @@ pub struct Settings { #[serde(default)] pub linux_graphics_mode: LinuxGraphicsMode, #[serde(default)] + pub linux_terminal_emulator: LinuxTerminalEmulator, + #[serde(default)] + pub linux_terminal_custom_command: String, + #[serde(default)] pub repo_open_behaviour: RepoOpenBehaviour, #[serde(default)] pub git_executable_path: String, @@ -284,6 +312,8 @@ impl Default for Settings { update_endpoint: Self::default_update_endpoint(), enable_update_with_ms_store_flow: false, linux_graphics_mode: LinuxGraphicsMode::Auto, + linux_terminal_emulator: LinuxTerminalEmulator::Auto, + linux_terminal_custom_command: String::new(), repo_open_behaviour: RepoOpenBehaviour::Ask, git_executable_path: String::new(), } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 25df22c..b7fe58a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -122,7 +122,10 @@ fn forward_reuse_window_action(action: &ShellStartupAction) -> bool { ContextAction::CloneRepo => instance_coordinator::CoordinatorCommand::OpenCloneWindow { options: CloneStartupOptions { repo_url: action.repo_url.clone(), - destination: action.destination.clone().or_else(|| Some(action.path.clone())), + destination: action + .destination + .clone() + .or_else(|| Some(action.path.clone())), start_clone: action.start_clone, }, }, @@ -1528,6 +1531,9 @@ pub fn run() { commands::settings::set_auto_install_updates, commands::settings::set_update_endpoint, commands::settings::set_linux_graphics_mode, + commands::settings::get_linux_terminal_options, + commands::settings::set_linux_terminal_emulator, + commands::settings::set_linux_terminal_custom_command, commands::settings::set_repo_open_behaviour, commands::history::get_commit_history, commands::history::verify_commits, diff --git a/src/api/commands.ts b/src/api/commands.ts index 372ab63..c3c093e 100644 --- a/src/api/commands.ts +++ b/src/api/commands.ts @@ -50,6 +50,8 @@ import type { SubmoduleActionRequest, TagInfo, BackendMode, + LinuxTerminalOption, + LinuxTerminalEmulator, ThemeMode, ThemeBundle, UiTextScale, @@ -460,6 +462,18 @@ export function setRepoOpenBehaviour(repoOpenBehaviour: RepoOpenBehaviour): Prom return invoke("set_repo_open_behaviour", {repoOpenBehaviour}); } +export function getLinuxTerminalOptions(): Promise { + return invoke("get_linux_terminal_options"); +} + +export function setLinuxTerminalEmulator(linuxTerminalEmulator: LinuxTerminalEmulator): Promise { + return invoke("set_linux_terminal_emulator", {linuxTerminalEmulator}); +} + +export function setLinuxTerminalCustomCommand(linuxTerminalCustomCommand: string): Promise { + return invoke("set_linux_terminal_custom_command", {linuxTerminalCustomCommand}); +} + export function getConfigFilePath(): Promise { return invoke("get_config_file_path"); } diff --git a/src/components/settings/SettingsWindow.test.tsx b/src/components/settings/SettingsWindow.test.tsx new file mode 100644 index 0000000..19598df --- /dev/null +++ b/src/components/settings/SettingsWindow.test.tsx @@ -0,0 +1,156 @@ +// @vitest-environment jsdom +import React from "react"; +import {fireEvent, render, screen, waitFor} from "@testing-library/react"; +import {beforeEach, describe, expect, it, vi} from "vitest"; +import type {Settings} from "../../types"; +import "../../i18n"; + +const mocks = vi.hoisted(() => ({ + close: vi.fn(), + emit: vi.fn(async () => {}), + openDialog: vi.fn(), + openPath: vi.fn(), + invoke: vi.fn(), +})); + +const settings: Settings = { + backendMode: "Default", + showResultLog: false, + themeMode: "System", + uiTextScale: 1, + wrapDiffLines: false, + rowStriping: "Off", + leftPaneWidth: 300, + rightPaneWidth: 420, + confirmRevert: true, + avatarProvider: "Libravatar", + tryPlatformFirst: true, + defaultCloneDir: "", + commitDateMode: "AuthorDate", + commitPrimaryAction: "commit", + commitMessageRecommendedLength: 72, + pushFollowTags: false, + autoCheckForUpdatesOnLaunch: true, + autoInstallUpdates: false, + updateEndpoint: "https://github.com/cst8t/gitmun/releases/latest/download/latest.json", + linuxGraphicsMode: "Auto", + linuxTerminalEmulator: "Auto", + linuxTerminalCustomCommand: "", + repoOpenBehaviour: "Ask", + gitExecutablePath: "", +}; + +mocks.invoke.mockImplementation(async (command: string) => { + switch (command) { + case "get_settings": + return settings; + case "get_active_git_executable_path": + return "/usr/bin/git"; + case "get_active_git_version": + return "git version 2.45.0"; + case "get_global_diff_tool": + return "Other"; + case "get_global_file_mode": + return true; + case "get_build_version": + return "0.1.0"; + case "set_theme_mode": + case "set_ui_text_scale": + case "set_wrap_diff_lines": + case "set_row_striping": + case "set_git_executable_path": + case "set_linux_graphics_mode": + case "set_linux_terminal_emulator": + case "set_linux_terminal_custom_command": + case "set_repo_open_behaviour": + return settings; + default: + return null; + } +}); + +vi.mock("@tauri-apps/api/core", () => ({invoke: mocks.invoke})); +vi.mock("@tauri-apps/api/event", () => ({emit: mocks.emit})); +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => ({close: mocks.close}), +})); +vi.mock("@tauri-apps/plugin-dialog", () => ({open: mocks.openDialog})); +vi.mock("@tauri-apps/plugin-opener", () => ({openPath: mocks.openPath})); +vi.mock("@tauri-apps/plugin-os", () => ({platform: () => "linux"})); +vi.mock("../../api/commands", () => ({ + getAppUpdateChannel: vi.fn(async () => "SystemManaged"), + getConfigFilePath: vi.fn(async () => "/home/conor/.config/gitmun/config.toml"), + getConfigFolderPath: vi.fn(async () => "/home/conor/.config/gitmun"), + getGlobalDiffToolPath: vi.fn(async () => null), + getGlobalGpgProgramPath: vi.fn(async () => null), + getLinuxTerminalOptions: vi.fn(async () => [ + {emulator: "Auto", label: "Terminal"}, + {emulator: "Ghostty", label: "Ghostty"}, + {emulator: "Custom", label: "Terminal"}, + ]), + openResultLogWindow: vi.fn(async () => {}), + setGlobalDiffToolWithPath: vi.fn(async () => ({message: "Updated diff tool."})), + setGlobalGpgProgram: vi.fn(async () => ({message: "Updated GPG executable."})), + setUpdateEndpoint: vi.fn(async () => settings), +})); + +import {SettingsWindow} from "./SettingsWindow"; + +describe("SettingsWindow", () => { + beforeEach(() => { + mocks.invoke.mockClear(); + mocks.emit.mockClear(); + mocks.close.mockClear(); + const store = new Map(); + vi.stubGlobal("localStorage", { + clear: vi.fn(() => store.clear()), + getItem: vi.fn((key: string) => store.get(key) ?? null), + removeItem: vi.fn((key: string) => { + store.delete(key); + }), + setItem: vi.fn((key: string, value: string) => { + store.set(key, value); + }), + }); + window.matchMedia = vi.fn().mockReturnValue({ + addEventListener: vi.fn(), + addListener: vi.fn(), + dispatchEvent: vi.fn(), + matches: false, + media: "", + onchange: null, + removeEventListener: vi.fn(), + removeListener: vi.fn(), + }); + }); + + it("shows the Linux custom terminal command only for Custom", async () => { + render(); + + expect(await screen.findByLabelText("Terminal")).toBeInTheDocument(); + expect(screen.queryByPlaceholderText("my-terminal --working-directory {path}")).not.toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText("Terminal"), {target: {value: "Custom"}}); + + expect(screen.getByPlaceholderText("my-terminal --working-directory {path}")).toBeInTheDocument(); + }); + + it("saves Linux terminal settings", async () => { + render(); + + fireEvent.change(await screen.findByLabelText("Terminal"), {target: {value: "Custom"}}); + fireEvent.change(screen.getByPlaceholderText("my-terminal --working-directory {path}"), { + target: {value: "kitty --directory {path}"}, + }); + fireEvent.click(screen.getByText("Save")); + + await waitFor(() => { + expect(mocks.invoke).toHaveBeenCalledWith("set_linux_terminal_emulator", { + linuxTerminalEmulator: "Custom", + }); + expect(mocks.invoke).toHaveBeenCalledWith("set_linux_terminal_custom_command", { + linuxTerminalCustomCommand: "kitty --directory {path}", + }); + }); + }); +}); diff --git a/src/components/settings/SettingsWindow.tsx b/src/components/settings/SettingsWindow.tsx index 15aa4f8..93599bd 100644 --- a/src/components/settings/SettingsWindow.tsx +++ b/src/components/settings/SettingsWindow.tsx @@ -13,6 +13,8 @@ import type { CommitDateMode, ExternalDiffTool, LinuxGraphicsMode, + LinuxTerminalEmulator, + LinuxTerminalOption, RepoOpenBehaviour, RowStriping, Settings, @@ -25,6 +27,7 @@ import { getConfigFolderPath, getGlobalDiffToolPath, getGlobalGpgProgramPath, + getLinuxTerminalOptions, openResultLogWindow, setGlobalDiffToolWithPath, setGlobalGpgProgram as saveGlobalGpgProgram, @@ -44,6 +47,10 @@ const LEFT_PANE_RATIO_KEY = "gitmun.leftPaneRatio"; const RIGHT_PANE_RATIO_KEY = "gitmun.rightPaneRatio"; const DEFAULT_UPDATE_ENDPOINT = "https://github.com/cst8t/gitmun/releases/latest/download/latest.json"; const DEFAULT_COMMIT_MESSAGE_RECOMMENDED_LENGTH = 72; +const DEFAULT_LINUX_TERMINAL_OPTIONS: LinuxTerminalOption[] = [ + {emulator: "Auto", label: "Terminal"}, + {emulator: "Custom", label: "Terminal"}, +]; function normaliseOptionalGitConfig(value: string | null | undefined): string { return value?.trim() ?? ""; @@ -155,6 +162,9 @@ export function SettingsWindow() { const [autoInstallUpdates, setAutoInstallUpdates] = useState(false); const [updateEndpoint, setUpdateEndpointState] = useState(DEFAULT_UPDATE_ENDPOINT); const [linuxGraphicsMode, setLinuxGraphicsMode] = useState("Auto"); + const [linuxTerminalOptions, setLinuxTerminalOptions] = useState(DEFAULT_LINUX_TERMINAL_OPTIONS); + const [linuxTerminalEmulator, setLinuxTerminalEmulator] = useState("Auto"); + const [linuxTerminalCustomCommand, setLinuxTerminalCustomCommand] = useState(""); const [repoOpenBehaviour, setRepoOpenBehaviour] = useState("Ask"); const [isLinux, setIsLinux] = useState(false); const [isWindows, setIsWindows] = useState(false); @@ -187,6 +197,16 @@ export function SettingsWindow() { return tool; } }, [t]); + const labelLinuxTerminal = useCallback((option: LinuxTerminalOption): string => { + switch (option.emulator) { + case "Auto": + return t("options.linuxTerminalAuto"); + case "Custom": + return t("options.linuxTerminalCustom"); + default: + return option.label; + } + }, [t]); const refreshGitExecutable = useCallback(async () => { const activeGitPath = await invoke("get_active_git_executable_path"); @@ -219,6 +239,9 @@ export function SettingsWindow() { setUpdateChannel(await getAppUpdateChannel()); setIsLinux(os === "linux"); setIsWindows(os === "windows"); + if (os === "linux") { + setLinuxTerminalOptions(await getLinuxTerminalOptions()); + } const globalDiffTool = await invoke("get_global_diff_tool"); setExternalDiffTool(supported.includes(globalDiffTool) ? globalDiffTool : "Other"); @@ -309,6 +332,8 @@ export function SettingsWindow() { setAutoInstallUpdates(settings.autoInstallUpdates ?? false); setUpdateEndpointState(settings.updateEndpoint ?? DEFAULT_UPDATE_ENDPOINT); setLinuxGraphicsMode(settings.linuxGraphicsMode ?? "Auto"); + setLinuxTerminalEmulator(settings.linuxTerminalEmulator ?? "Auto"); + setLinuxTerminalCustomCommand(settings.linuxTerminalCustomCommand ?? ""); setRepoOpenBehaviour(settings.repoOpenBehaviour ?? "Ask"); await applyThemeMode(settings.themeMode); applyUiTextScale(settings.uiTextScale); @@ -449,7 +474,11 @@ export function SettingsWindow() { await invoke("set_auto_check_for_updates_on_launch", {autoCheckForUpdatesOnLaunch}); await invoke("set_auto_install_updates", {autoInstallUpdates}); await setUpdateEndpoint(updateEndpoint); - if (isLinux) await invoke("set_linux_graphics_mode", {mode: linuxGraphicsMode}); + if (isLinux) { + await invoke("set_linux_graphics_mode", {mode: linuxGraphicsMode}); + await invoke("set_linux_terminal_emulator", {linuxTerminalEmulator}); + await invoke("set_linux_terminal_custom_command", {linuxTerminalCustomCommand}); + } await invoke("set_repo_open_behaviour", {repoOpenBehaviour}); const settings = await invoke("get_settings"); setCommitMessageRecommendedLength(String(settings.commitMessageRecommendedLength ?? DEFAULT_COMMIT_MESSAGE_RECOMMENDED_LENGTH)); @@ -585,6 +614,8 @@ export function SettingsWindow() { isLinux, isWindows, linuxGraphicsMode, + linuxTerminalEmulator, + linuxTerminalCustomCommand, repoOpenBehaviour, externalDiffToolPath, globalGpgProgram, @@ -770,6 +801,39 @@ export function SettingsWindow() { + {isLinux && ( +
+ + + {linuxTerminalEmulator === "Custom" && ( + setLinuxTerminalCustomCommand(e.target.value)} + placeholder={t("placeholders.linuxTerminalCustomCommand")} + spellCheck={false} + autoCapitalize="off" + autoCorrect="off" + /> + )} +
+ {t("notes.linuxTerminal")} +
+
+ )} +
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 7fedf8b..4570980 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -54,6 +54,7 @@ "resultLog": "Result log", "rowStriping": "Row striping", "settings": "Settings", + "terminal": "Terminal", "textScale": "Text size", "theme": "Theme", "updateFeedUrl": "Update feed URL", @@ -70,6 +71,7 @@ "gpgProgram": "Leave blank to use Git's default GPG lookup. On Windows, Gitmun also searches common Git for Windows and GnuPG install folders.", "lineEndings": "Changing line ending conversion can make many files appear modified until the repository is renormalised.", "linuxGraphics": "Takes effect on next launch. Use \"Maximum compatibility\" if you see rendering crashes or a blank window.", + "linuxTerminal": "Custom commands can use {path}. If omitted, Gitmun still starts the terminal from the repository folder.", "commitMessageRecommendedLength": "Recommended maximum for the first line of a commit message. Set to 0 to disable.", "repoOpenBehaviour": "Controls in-app repository opens, including recent repositories. Shell and file manager launches open a new window unless the launcher asks to reuse one.", "rowStriping": "Alternates list row backgrounds in changes and commit views for easier scanning.", @@ -113,6 +115,12 @@ "graphicsAuto": "Compatibility (default)", "graphicsNative": "Native (hardware acceleration)", "graphicsSafe": "Maximum compatibility", + "linuxTerminalAuto": "Auto-detect (default)", + "linuxTerminalCustom": "Custom command", + "linuxTerminalGnomeConsole": "GNOME Console", + "linuxTerminalGnomeTerminal": "GNOME Terminal", + "linuxTerminalMateTerminal": "MATE Terminal", + "linuxTerminalXfce4Terminal": "Xfce Terminal", "repoOpenAsk": "Ask each time (default)", "repoOpenExisting": "Reuse this window", "repoOpenNew": "Always open a new window", @@ -142,7 +150,8 @@ "credentialHelper": "Leave blank for Git's default helper lookup", "gitEditor": "e.g. code --wait, nano, vim", "gitExecutable": "Auto-detect Git or choose an executable", - "gpgProgram": "Leave blank for Git's default, or choose gpg" + "gpgProgram": "Leave blank for Git's default, or choose gpg", + "linuxTerminalCustomCommand": "my-terminal --working-directory {path}" }, "picker": { "cloneDestination": "Select default clone destination", diff --git a/src/types.ts b/src/types.ts index 8f4ffe5..14bfbc6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,25 @@ export type CommitDateMode = "AuthorDate" | "CommitterDate"; export type CommitLogScope = "currentCheckout" | "allRefs"; export type CommitPrimaryAction = "commit" | "commitAndPush"; export type LinuxGraphicsMode = "Auto" | "Safe" | "Native"; +export type LinuxTerminalEmulator = + | "Auto" + | "Konsole" + | "GnomeTerminal" + | "GnomeConsole" + | "Xfce4Terminal" + | "MateTerminal" + | "Lxterminal" + | "Alacritty" + | "Ghostty" + | "Kitty" + | "WezTerm" + | "Foot" + | "Xterm" + | "Custom"; +export type LinuxTerminalOption = { + emulator: LinuxTerminalEmulator; + label: string; +}; export type RepoOpenBehaviour = "Ask" | "ExistingWindow" | "NewWindow"; export type RowStriping = "Off" | "Subtle" | "Strong"; export type UiTextScale = 0.9 | 1 | 1.1 | 1.2 | 1.3; @@ -60,6 +79,8 @@ export type Settings = { updateEndpoint: string; enableUpdateWithMSStoreFlow?: boolean; linuxGraphicsMode: LinuxGraphicsMode; + linuxTerminalEmulator: LinuxTerminalEmulator; + linuxTerminalCustomCommand: string; repoOpenBehaviour: RepoOpenBehaviour; gitExecutablePath: string; }; From 2c981040fca2c9c63685238420bf0584f5c7f68f Mon Sep 17 00:00:00 2001 From: cst8t <1810150+cst8t@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:05:24 +0100 Subject: [PATCH 3/5] feat: support commit message bodies Split the commit editor into subject and body fields, preserve multi-line messages through git commit --file, and add focused regression coverage. --- src-tauri/src/git/cli.rs | 39 ++- src-tauri/tests/git.rs | 20 ++ src/components/centre/CentrePanel.css | 70 ++++- src/components/centre/CommitBox.test.tsx | 319 +++++++++++++++++++++-- src/components/centre/CommitBox.tsx | 245 ++++++++++++++--- src/i18n/locales/en/centre.json | 5 + 6 files changed, 629 insertions(+), 69 deletions(-) diff --git a/src-tauri/src/git/cli.rs b/src-tauri/src/git/cli.rs index 727f13b..8ce3ebc 100644 --- a/src-tauri/src/git/cli.rs +++ b/src-tauri/src/git/cli.rs @@ -462,6 +462,37 @@ impl CliGitHandler { } } + fn write_commit_message_file(message: &str) -> GitResult { + let temp_dir = std::env::temp_dir(); + let process_id = std::process::id(); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + + for attempt in 0..100 { + let path = temp_dir.join(format!( + "gitmun-commit-message-{process_id}-{timestamp}-{attempt}.txt" + )); + let mut file = match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error.into()), + }; + file.write_all(message.as_bytes())?; + file.write_all(b"\n")?; + return Ok(path); + } + + Err(GitError::IoError( + "Could not create a temporary commit message file".to_string(), + )) + } + fn validate_repo_relative_path(path: &str) -> GitResult { let trimmed = path.trim(); if trimmed.is_empty() { @@ -1942,7 +1973,9 @@ impl GitOperationHandler for CliGitHandler { )); } - let mut args = vec!["commit", "-m", message]; + let message_file_path = Self::write_commit_message_file(message)?; + let message_file = Self::path_to_string(&message_file_path); + let mut args = vec!["commit", "--file", message_file.as_str()]; let commit_gpgsign = Self::run_git_allow_exit_codes( &["config", "--get", "commit.gpgsign"], Some(&repo_path), @@ -1980,7 +2013,9 @@ impl GitOperationHandler for CliGitHandler { if request.amend == Some(true) { args.push("--amend"); } - let output = Self::run_git(&args, Some(&repo_path))?; + let output = Self::run_git(&args, Some(&repo_path)); + let _ = fs::remove_file(&message_file_path); + let output = output?; Ok(OperationResult { message: format!("Committed changes in {}", repo_path.display()), diff --git a/src-tauri/tests/git.rs b/src-tauri/tests/git.rs index 27caf13..fc67b1b 100644 --- a/src-tauri/tests/git.rs +++ b/src-tauri/tests/git.rs @@ -819,6 +819,26 @@ fn commit_creates_entry_in_log() { assert!(commits.iter().any(|c| c.message == "add b.txt")); } +#[test] +fn commit_preserves_description_and_trailer_like_lines() { + let dir = init_repo(); + write_file(dir.path(), "body.txt", "data"); + git(dir.path(), &["add", "body.txt"]); + let message = + "add body\n\nExplain the change\n\nCo-authored-by: Name "; + + handler() + .commit_changes(&CommitRequest { + repo_path: dir.path().to_str().unwrap().to_string(), + message: message.to_string(), + amend: None, + }) + .expect("commit_changes"); + + let committed_message = git_stdout(dir.path(), &["log", "-1", "--format=%B"]); + assert_eq!(committed_message, message); +} + #[test] fn commit_history_all_refs_includes_branch_outside_detached_head() { let dir = init_repo(); diff --git a/src/components/centre/CentrePanel.css b/src/components/centre/CentrePanel.css index 53bf6f7..9357269 100644 --- a/src/components/centre/CentrePanel.css +++ b/src/components/centre/CentrePanel.css @@ -318,14 +318,43 @@ border-top: 1px solid var(--border); padding: 14px; flex-shrink: 0; + position: relative; + display: flex; + flex-direction: column; + height: var(--commit-box-height, auto); + min-height: 214px; +} + +.commit-box__resize-handle { + position: absolute; + top: -5px; + left: 0; + right: 0; + height: 10px; + cursor: row-resize; +} + +.commit-box__resize-handle::after { + content: ""; + position: absolute; + top: 4px; + left: 0; + right: 0; + height: 1px; + background: transparent; +} + +.commit-box__resize-handle:hover::after, +.commit-box--resizing .commit-box__resize-handle::after { + background: var(--accent); } .commit-box__amend { display: flex; align-items: center; gap: 6px; - margin-bottom: 8px; cursor: pointer; + min-width: 0; } .commit-box__checkbox { @@ -349,10 +378,12 @@ .commit-box__amend-label { font-size: var(--font-size-sm); color: var(--text-muted); + white-space: nowrap; } .commit-box__amend-label--active { color: var(--accent); } +.commit-box__subject, .commit-box__textarea { width: 100%; background: var(--bg-elevated); @@ -362,26 +393,54 @@ padding: 10px 12px; font-size: var(--font-size-base); font-family: var(--font-ui); - resize: none; - min-height: 60px; outline: none; box-sizing: border-box; transition: border-color 0.2s; } +.commit-box__subject { + flex: 0 0 auto; + height: 38px; + margin-bottom: 6px; +} + +.commit-box__textarea { + flex: 1 1 auto; + resize: none; + min-height: 72px; +} + +.commit-box__subject:focus, .commit-box__textarea:focus { border-color: var(--accent); } -.commit-box__textarea--warn { border-color: var(--diff-del-border); } -.commit-box__hints { +.commit-box__subject::placeholder, +.commit-box__textarea::placeholder { color: var(--text-muted); } + +.commit-box__subject--warn { border-color: var(--diff-del-border); } + +.commit-box__meta { + flex: 0 0 auto; display: flex; justify-content: space-between; + align-items: center; + gap: 10px; margin-top: 4px; margin-bottom: 8px; } +.commit-box__hints { + display: flex; + justify-content: flex-end; + gap: 8px; + min-width: 0; +} + .commit-box__hint { font-size: var(--font-size-xxs); color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .commit-box__hint--error { color: var(--red); } @@ -393,6 +452,7 @@ } .commit-box__actions { + flex: 0 0 auto; position: relative; display: flex; } diff --git a/src/components/centre/CommitBox.test.tsx b/src/components/centre/CommitBox.test.tsx index 3ed4fae..e1762a5 100644 --- a/src/components/centre/CommitBox.test.tsx +++ b/src/components/centre/CommitBox.test.tsx @@ -1,39 +1,84 @@ // @vitest-environment jsdom import React from "react"; -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CommitBox } from "./CommitBox"; import type { CommitPrimaryAction } from "../../types"; import "../../i18n"; -function renderCommitBox(selectedAction: CommitPrimaryAction = "commit", commitMessageRecommendedLength = 72, allowCommitAndPush = true) { +const COMMIT_BOX_RATIO_KEY = "gitmun.commitBoxRatio"; + +function makeLocalStorage() { + const store: Record = {}; + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { + store[key] = value; + }, + removeItem: (key: string) => { + delete store[key]; + }, + }; +} + +type RenderCommitBoxOptions = { + selectedAction?: CommitPrimaryAction; + commitMessageRecommendedLength?: number; + allowCommitAndPush?: boolean; + lastCommitMessage?: string; + mergeMessage?: string | null; + mergeInProgress?: boolean; +}; + +function renderCommitBox({ + selectedAction = "commit", + commitMessageRecommendedLength = 72, + allowCommitAndPush = true, + lastCommitMessage = "", + mergeMessage, + mergeInProgress, +}: RenderCommitBoxOptions = {}) { const onCommit = vi.fn(); const onSelectAction = vi.fn(); const view = render( - , +
+ +
, ); return { ...view, onCommit, onSelectAction }; } describe("CommitBox", () => { + beforeEach(() => { + vi.stubGlobal("localStorage", makeLocalStorage()); + localStorage.removeItem(COMMIT_BOX_RATIO_KEY); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + it("shows Commit as the default primary action", () => { - renderCommitBox("commit"); + renderCommitBox({selectedAction: "commit"}); expect(screen.getByRole("button", { name: "Commit (2)" })).toBeInTheDocument(); }); it("updates the primary action label from props", () => { - const { rerender, onCommit, onSelectAction } = renderCommitBox("commit"); + const { rerender, onCommit, onSelectAction } = renderCommitBox({selectedAction: "commit"}); rerender( { }); it("calls onSelectAction when the user chooses a different default action", () => { - const { onSelectAction } = renderCommitBox("commit"); + const { onSelectAction } = renderCommitBox({selectedAction: "commit"}); fireEvent.click(screen.getByRole("button", { name: "Choose commit action" })); fireEvent.click(screen.getByRole("menuitemradio", { name: "Commit and Push" })); @@ -61,9 +106,9 @@ describe("CommitBox", () => { }); it("submits the selected primary action", () => { - const { onCommit } = renderCommitBox("commitAndPush"); + const { onCommit } = renderCommitBox({selectedAction: "commitAndPush"}); - fireEvent.change(screen.getByPlaceholderText("Commit message..."), { + fireEvent.change(screen.getByPlaceholderText("Commit subject..."), { target: { value: "Ship it" }, }); fireEvent.click(screen.getByRole("button", { name: "Commit and Push (2)" })); @@ -71,21 +116,76 @@ describe("CommitBox", () => { expect(onCommit).toHaveBeenCalledWith("Ship it", false, "commitAndPush"); }); + it("submits subject-only commit messages", () => { + const { onCommit } = renderCommitBox(); + + fireEvent.change(screen.getByPlaceholderText("Commit subject..."), { + target: { value: "Subject" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Commit (2)" })); + + expect(onCommit).toHaveBeenCalledWith("Subject", false, "commit"); + }); + + it("submits subject and body commit messages", () => { + const { onCommit } = renderCommitBox(); + + fireEvent.change(screen.getByPlaceholderText("Commit subject..."), { + target: { value: "Subject" }, + }); + fireEvent.change(screen.getByPlaceholderText("Commit body..."), { + target: { value: "Body" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Commit (2)" })); + + expect(onCommit).toHaveBeenCalledWith("Subject\n\nBody", false, "commit"); + }); + + it("keeps commit disabled when only the body is present", () => { + const { onCommit } = renderCommitBox(); + + fireEvent.change(screen.getByPlaceholderText("Commit body..."), { + target: { value: "Body" }, + }); + + expect(screen.getByRole("button", { name: "Commit (2)" })).toBeDisabled(); + + fireEvent.click(screen.getByRole("button", { name: "Commit (2)" })); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it("trims outer whitespace while preserving internal body blank lines", () => { + const { onCommit } = renderCommitBox(); + + fireEvent.change(screen.getByPlaceholderText("Commit subject..."), { + target: { value: " Subject " }, + }); + fireEvent.change(screen.getByPlaceholderText("Commit body..."), { + target: { value: " Body line\n\nNext line " }, + }); + fireEvent.click(screen.getByRole("button", { name: "Commit (2)" })); + + expect(onCommit).toHaveBeenCalledWith("Subject\n\nBody line\n\nNext line", false, "commit"); + }); + it("uses the configured recommended subject length", () => { - renderCommitBox("commit", 10); + renderCommitBox({selectedAction: "commit", commitMessageRecommendedLength: 10}); - fireEvent.change(screen.getByPlaceholderText("Commit message..."), { + fireEvent.change(screen.getByPlaceholderText("Commit subject..."), { target: { value: "Long subject" }, }); + fireEvent.change(screen.getByPlaceholderText("Commit body..."), { + target: { value: "This body is longer than the subject limit" }, + }); expect(screen.getByText("Subject line exceeds 10 characters")).toBeInTheDocument(); expect(screen.getByText("12/10")).toBeInTheDocument(); }); it("disables the subject length check when the recommended length is zero", () => { - renderCommitBox("commit", 0); + renderCommitBox({selectedAction: "commit", commitMessageRecommendedLength: 0}); - fireEvent.change(screen.getByPlaceholderText("Commit message..."), { + fireEvent.change(screen.getByPlaceholderText("Commit subject..."), { target: { value: "Long subject" }, }); @@ -94,20 +194,189 @@ describe("CommitBox", () => { }); it("hides the action menu when commit and push is unavailable", () => { - renderCommitBox("commitAndPush", 72, false); + renderCommitBox({ + selectedAction: "commitAndPush", + commitMessageRecommendedLength: 72, + allowCommitAndPush: false, + }); expect(screen.getByRole("button", { name: "Commit (2)" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Choose commit action" })).not.toBeInTheDocument(); }); it("submits commit when commit and push is unavailable", () => { - const { onCommit } = renderCommitBox("commitAndPush", 72, false); + const { onCommit } = renderCommitBox({ + selectedAction: "commitAndPush", + commitMessageRecommendedLength: 72, + allowCommitAndPush: false, + }); - fireEvent.change(screen.getByPlaceholderText("Commit message..."), { + fireEvent.change(screen.getByPlaceholderText("Commit subject..."), { target: { value: "Ship it" }, }); fireEvent.click(screen.getByRole("button", { name: "Commit (2)" })); expect(onCommit).toHaveBeenCalledWith("Ship it", false, "commit"); }); + + it("keeps commit disabled until the message is present", () => { + renderCommitBox(); + + expect(screen.getByRole("button", { name: "Commit (2)" })).toBeDisabled(); + expect(screen.getByText("Message required to commit")).toBeInTheDocument(); + }); + + it("prefills amend message from the latest commit", () => { + const { onCommit } = renderCommitBox({ + lastCommitMessage: "Existing subject\n\nExisting body", + }); + + fireEvent.click(screen.getByText("Amend latest commit")); + expect(screen.getByPlaceholderText("Amend commit subject...")).toHaveValue("Existing subject"); + expect(screen.getByPlaceholderText("Amend commit body...")).toHaveValue("Existing body"); + + fireEvent.click(screen.getByRole("button", { name: "Amend (2)" })); + expect(onCommit).toHaveBeenCalledWith("Existing subject\n\nExisting body", true, "commit"); + }); + + it("prefills merge message without comment lines", () => { + renderCommitBox({ + mergeInProgress: true, + mergeMessage: "Merge branch 'feature'\n\nResolve conflicts\n# Please enter a commit message", + }); + + expect(screen.getByPlaceholderText("Commit subject...")).toHaveValue("Merge branch 'feature'"); + expect(screen.getByPlaceholderText("Commit body...")).toHaveValue("Resolve conflicts"); + }); + + it("commits with Cmd or Ctrl Enter while the subject is focused", () => { + const { onCommit } = renderCommitBox(); + + fireEvent.change(screen.getByPlaceholderText("Commit subject..."), { + target: { value: "Ship from subject" }, + }); + fireEvent.keyDown(screen.getByPlaceholderText("Commit subject..."), { + key: "Enter", + metaKey: true, + }); + + expect(onCommit).toHaveBeenCalledWith("Ship from subject", false, "commit"); + }); + + it("commits with Cmd or Ctrl Enter while the body is focused", () => { + const { onCommit, unmount } = renderCommitBox(); + + fireEvent.change(screen.getByPlaceholderText("Commit subject..."), { + target: { value: "Ship from body" }, + }); + fireEvent.change(screen.getByPlaceholderText("Commit body..."), { + target: { value: "Body" }, + }); + fireEvent.keyDown(screen.getByPlaceholderText("Commit body..."), { + key: "Enter", + metaKey: true, + }); + + expect(onCommit).toHaveBeenCalledWith("Ship from body\n\nBody", false, "commit"); + + unmount(); + const second = renderCommitBox(); + fireEvent.change(screen.getByPlaceholderText("Commit subject..."), { + target: { value: "Ship from ctrl" }, + }); + fireEvent.change(screen.getByPlaceholderText("Commit body..."), { + target: { value: "Body" }, + }); + fireEvent.keyDown(screen.getByPlaceholderText("Commit body..."), { + key: "Enter", + ctrlKey: true, + }); + + expect(second.onCommit).toHaveBeenCalledWith("Ship from ctrl\n\nBody", false, "commit"); + }); + + it("scales the commit editor height from the saved ratio as the staging area changes", async () => { + localStorage.setItem(COMMIT_BOX_RATIO_KEY, "0.4"); + let stagingHeight = 1000; + const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; + vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) { + if ((this as HTMLElement).classList.contains("commit-box-test-host")) { + return { + x: 0, + y: 0, + width: 420, + height: stagingHeight, + top: 0, + right: 420, + bottom: stagingHeight, + left: 0, + toJSON: () => ({}), + }; + } + return originalGetBoundingClientRect.call(this); + }); + + const { container } = renderCommitBox(); + const commitBox = container.querySelector(".commit-box") as HTMLElement; + + await waitFor(() => { + expect(commitBox.style.getPropertyValue("--commit-box-height")).toBe("400px"); + }); + + stagingHeight = 1200; + fireEvent(window, new Event("resize")); + + await waitFor(() => { + expect(commitBox.style.getPropertyValue("--commit-box-height")).toBe("480px"); + }); + }); + + it("saves the resized commit editor ratio after dragging the divider", () => { + let commitBoxHeight = 214; + const stagingHeight = 1000; + const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; + vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) { + const element = this as HTMLElement; + if (element.classList.contains("commit-box-test-host")) { + return { + x: 0, + y: 0, + width: 420, + height: stagingHeight, + top: 0, + right: 420, + bottom: stagingHeight, + left: 0, + toJSON: () => ({}), + }; + } + if (element.classList.contains("commit-box")) { + return { + x: 0, + y: stagingHeight - commitBoxHeight, + width: 420, + height: commitBoxHeight, + top: stagingHeight - commitBoxHeight, + right: 420, + bottom: stagingHeight, + left: 0, + toJSON: () => ({}), + }; + } + return originalGetBoundingClientRect.call(this); + }); + + renderCommitBox(); + + fireEvent.mouseDown(screen.getByRole("separator", { name: "Resize commit message editor" }), { + clientY: 500, + }); + commitBoxHeight = 314; + fireEvent.mouseMove(window, { + clientY: 400, + }); + fireEvent.mouseUp(window); + + expect(localStorage.getItem(COMMIT_BOX_RATIO_KEY)).toBe("0.314000"); + }); }); diff --git a/src/components/centre/CommitBox.tsx b/src/components/centre/CommitBox.tsx index 23ee5d3..14c95e8 100644 --- a/src/components/centre/CommitBox.tsx +++ b/src/components/centre/CommitBox.tsx @@ -19,6 +19,16 @@ type CommitBoxProps = { cherryPickInProgress?: boolean; }; +type CommitBoxDragState = { + startY: number; + startHeight: number; + totalHeight: number; +}; + +const MIN_COMMIT_BOX_HEIGHT = 214; +const MIN_STAGING_FILES_HEIGHT = 120; +const COMMIT_BOX_RATIO_KEY = "gitmun.commitBoxRatio"; + function getCommitButtonLabel( action: CommitPrimaryAction, stagedCount: number, @@ -47,6 +57,45 @@ function getCommitButtonLabel( : translate("commitBox.commitButton", {count: stagedCount}); } +function splitCommitMessage(message: string) { + const newlineIndex = message.indexOf("\n"); + if (newlineIndex === -1) return { subject: message, body: "" }; + + const subject = message.slice(0, newlineIndex); + const rest = message.slice(newlineIndex + 1); + const body = rest.startsWith("\n") ? rest.slice(1) : rest; + return { subject, body }; +} + +function parseCommitBoxRatio(value: string | null): number | null { + if (!value) return null; + const parsed = Number.parseFloat(value); + if (!Number.isFinite(parsed)) return null; + if (parsed <= 0 || parsed >= 1) return null; + return parsed; +} + +function clampCommitBoxHeight(totalHeight: number, desiredHeight: number): number { + if (!Number.isFinite(totalHeight) || totalHeight <= 0) return MIN_COMMIT_BOX_HEIGHT; + + const maxHeight = Math.max(MIN_COMMIT_BOX_HEIGHT, totalHeight - MIN_STAGING_FILES_HEIGHT); + return Math.round(Math.min(Math.max(desiredHeight, MIN_COMMIT_BOX_HEIGHT), maxHeight)); +} + +function commitBoxRatioFromHeight(totalHeight: number, height: number): number | null { + if (!Number.isFinite(totalHeight) || totalHeight <= 0) return null; + const ratio = height / totalHeight; + return ratio > 0 && ratio < 1 ? ratio : null; +} + +function getCommitBoxStorage(): Storage | null { + try { + return typeof localStorage === "undefined" ? null : localStorage; + } catch { + return null; + } +} + export function CommitBox({ stagedCount, selectedAction, @@ -62,22 +111,29 @@ export function CommitBox({ cherryPickInProgress, }: CommitBoxProps) { const { t } = useTranslation("centre"); - const [message, setMessage] = useState(""); + const [subject, setSubject] = useState(""); + const [body, setBody] = useState(""); const [amend, setAmend] = useState(false); const [menuOpen, setMenuOpen] = useState(false); + const [commitBoxHeight, setCommitBoxHeight] = useState(null); + const [dragState, setDragState] = useState(null); const menuRef = useRef(null); + const commitBoxRef = useRef(null); + const commitBoxHeightRef = useRef(MIN_COMMIT_BOX_HEIGHT); + const commitBoxRatioRef = useRef(parseCommitBoxRatio(getCommitBoxStorage()?.getItem(COMMIT_BOX_RATIO_KEY) ?? null)); const activeAction = allowCommitAndPush ? selectedAction : "commit"; useEffect(() => { if (mergeInProgress && mergeMessage) { const cleaned = mergeMessage.split("\n").filter(l => !l.startsWith("#")).join("\n").trim(); - setMessage(cleaned); + const nextMessage = splitCommitMessage(cleaned); + setSubject(nextMessage.subject); + setBody(nextMessage.body); } else if (!mergeInProgress) { - setMessage(""); + setSubject(""); + setBody(""); } - // Only re-run when merge state transitions - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mergeInProgress]); + }, [mergeInProgress, mergeMessage]); useEffect(() => { if (!menuOpen) return; @@ -96,59 +152,174 @@ export function CommitBox({ } }, [allowCommitAndPush]); - const subjectLine = message.split("\n")[0] ?? ""; - const subjectLength = subjectLine.length; + useEffect(() => { + const root = commitBoxRef.current?.parentElement; + if (!root) return; + + const applyLayout = () => { + const ratio = commitBoxRatioRef.current; + if (ratio == null) return; + + const totalHeight = root.getBoundingClientRect().height; + if (totalHeight <= 0) return; + const nextHeight = clampCommitBoxHeight(totalHeight, totalHeight * ratio); + commitBoxHeightRef.current = nextHeight; + setCommitBoxHeight(nextHeight); + }; + + applyLayout(); + const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(applyLayout); + observer?.observe(root); + window.addEventListener("resize", applyLayout); + return () => { + observer?.disconnect(); + window.removeEventListener("resize", applyLayout); + }; + }, []); + + useEffect(() => { + if (!dragState) return; + + const handleMouseMove = (event: MouseEvent) => { + const nextHeight = dragState.startHeight + dragState.startY - event.clientY; + const clampedHeight = clampCommitBoxHeight(dragState.totalHeight, nextHeight); + commitBoxHeightRef.current = clampedHeight; + setCommitBoxHeight(clampedHeight); + }; + const handleMouseUp = () => { + setDragState(null); + const totalHeight = commitBoxRef.current?.parentElement?.getBoundingClientRect().height ?? 0; + const ratio = commitBoxRatioFromHeight(totalHeight, commitBoxHeightRef.current); + const storage = getCommitBoxStorage(); + commitBoxRatioRef.current = ratio; + if (ratio == null) { + storage?.removeItem(COMMIT_BOX_RATIO_KEY); + } else { + storage?.setItem(COMMIT_BOX_RATIO_KEY, ratio.toFixed(6)); + } + }; + + document.body.style.cursor = "row-resize"; + document.body.style.userSelect = "none"; + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + return () => { + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [dragState]); + + const trimmedSubject = subject.trim(); + const trimmedBody = body.trim(); + const subjectLength = subject.length; const hasRecommendedLength = commitMessageRecommendedLength > 0; const subjectOverflow = hasRecommendedLength && subjectLength > commitMessageRecommendedLength; const actionDisabled = - stagedCount === 0 || message.trim() === "" || isCommitting || rebaseInProgress || cherryPickInProgress; + stagedCount === 0 || trimmedSubject === "" || isCommitting || rebaseInProgress || cherryPickInProgress; const handleAmendToggle = () => { const next = !amend; setAmend(next); - if (next && lastCommitMessage) setMessage(lastCommitMessage); + if (next && lastCommitMessage) { + const nextMessage = splitCommitMessage(lastCommitMessage); + setSubject(nextMessage.subject); + setBody(nextMessage.body); + } }; const handleCommit = () => { if (actionDisabled) return; - onCommit(message.trim(), amend, activeAction); - setMessage(""); + const message = trimmedBody === "" ? trimmedSubject : `${trimmedSubject}\n\n${trimmedBody}`; + onCommit(message, amend, activeAction); + setSubject(""); + setBody(""); setAmend(false); setMenuOpen(false); }; + const handleCommitKeyDown = ( + event: React.KeyboardEvent, + ) => { + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + handleCommit(); + } + }; + + const handleResizeMouseDown = (event: React.MouseEvent) => { + event.preventDefault(); + const commitBoxRect = commitBoxRef.current?.getBoundingClientRect(); + const stagingRect = commitBoxRef.current?.parentElement?.getBoundingClientRect(); + if (!commitBoxRect || !stagingRect) return; + + setCommitBoxHeight(commitBoxRect.height); + commitBoxHeightRef.current = commitBoxRect.height; + setDragState({ + startY: event.clientY, + startHeight: commitBoxRect.height, + totalHeight: stagingRect.height, + }); + }; + return ( -
-
-
- {amend && } -
- - {t("commitBox.amend")} - -
+
+
+ + 0 ? "commit-box__subject--warn" : ""}`} + value={subject} + onChange={e => setSubject(e.target.value)} + onKeyDown={handleCommitKeyDown} + placeholder={amend ? t("commitBox.amendSubject") : t("commitBox.commitSubject")} + spellCheck="true" + data-allow-native-context-menu="true" + />