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..9122d05 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,9 +1603,12 @@ name = "gitmun" version = "0.1.0" dependencies = [ "base64 0.22.1", + "clap", + "clap_complete", "gix", "gtk", "infer", + "linux-terminal-launch", "md5", "mime_guess", "notify", @@ -3025,6 +3133,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" @@ -3326,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" @@ -3796,6 +3915,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 +6331,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..51be5bb 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" @@ -39,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..db4515c 100644 --- a/src-tauri/src/commands/repo.rs +++ b/src-tauri/src/commands/repo.rs @@ -6,11 +6,13 @@ use crate::git::types::{ PullStrategyRequest, PushRequest, PushResult, RepoRequest, RepoStatus, SetIdentityRequest, StageFilesRequest, StashEntry, StashPushRequest, StashRequest, SubmoduleActionRequest, }; +#[cfg(target_os = "linux")] +use crate::git::types::LinuxTerminalEmulator; 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 +35,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 +46,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 +74,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 +89,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 +147,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 +203,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 +212,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 +232,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 +242,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/cli.rs b/src-tauri/src/git/cli.rs index 727f13b..580662e 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()), @@ -4296,6 +4331,7 @@ impl GitOperationHandler for CliGitHandler { let mode_flag = match request.mode { ResetMode::Soft => "--soft", ResetMode::Mixed => "--mixed", + ResetMode::Hard => "--hard", }; Self::run_git( &["reset", mode_flag, request.target.trim()], @@ -4304,6 +4340,7 @@ impl GitOperationHandler for CliGitHandler { let mode_label = match request.mode { ResetMode::Soft => "soft", ResetMode::Mixed => "mixed", + ResetMode::Hard => "hard", }; Ok(OperationResult { message: format!("Reset ({mode_label}) to {}", request.target.trim()), @@ -5239,6 +5276,15 @@ mod tests { ); } + #[test] + fn reset_mode_hard_serialises_as_hard() { + assert_eq!(serde_json::to_string(&ResetMode::Hard).unwrap(), "\"hard\""); + assert_eq!( + serde_json::from_str::("\"hard\"").unwrap(), + ResetMode::Hard + ); + } + #[test] fn status_line_modified_unstaged() { let result = CliGitHandler::parse_status_line(" M src/main.rs"); 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..7c0d684 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(), } @@ -1072,6 +1102,7 @@ pub struct StashPushRequest { pub enum ResetMode { Soft, Mixed, + Hard, } #[derive(Debug, Clone, Deserialize)] 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..b7fe58a 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,37 @@ 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 +1292,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 +1305,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 +1336,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 +1345,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 +1368,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 +1402,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 +1445,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| { @@ -1474,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, @@ -1564,7 +1624,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..862e673 100644 --- a/src-tauri/src/shell/cli.rs +++ b/src-tauri/src/shell/cli.rs @@ -1,154 +1,388 @@ -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) } - _ => {} } } +} - 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()) +} + +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 parses_bare_launch() { + assert_eq!(parse(&["gitmun"]), CliOutcome::Launch(None)); + } + #[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_positional_path_as_open_repo() { assert_eq!( - result, - Some(ShellStartupAction { + parse(&["gitmun", "."]), + CliOutcome::Launch(Some(ShellStartupAction { action: ContextAction::OpenRepo, - path: "/home/user/project".to_string(), - }) + path: cwd_path("."), + 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_open_command() { + let path = cwd_path("project"); + assert_eq!( - result, - Some(ShellStartupAction { - action: ContextAction::CloneHere, - path: "/home/user/dir".to_string(), - }) + parse(&["gitmun", "open", &path]), + CliOutcome::Launch(Some(ShellStartupAction { + action: ContextAction::OpenRepo, + path, + 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_defaulting_to_current_dir() { assert_eq!( - result, - Some(ShellStartupAction { - action: ContextAction::OpenRepo, - path: "/home/user/project".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_no_args() { - let args = vec!["/usr/bin/gitmun".to_string()]; - let result = parse_shell_action(&args); - assert_eq!(result, None); + fn parses_clone_repo_and_destination() { + let destination = cwd_path("projects/repo"); + + assert_eq!( + parse(&[ + "gitmun", + "clone", + "git@github.com:owner/repo.git", + &destination, + ]), + CliOutcome::Launch(Some(ShellStartupAction { + action: ContextAction::CloneRepo, + path: destination.clone(), + routing: None, + repo_url: Some("git@github.com:owner/repo.git".to_string()), + destination: Some(destination), + start_clone: false, + })) + ); + } + + #[test] + fn parses_clone_destination_option() { + let destination = cwd_path("projects"); + + assert_eq!( + parse(&["gitmun", "clone", "--to", &destination]), + CliOutcome::Launch(Some(ShellStartupAction { + action: ContextAction::CloneRepo, + path: destination.clone(), + routing: None, + repo_url: None, + destination: Some(destination), + 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-tauri/tests/git.rs b/src-tauri/tests/git.rs index 27caf13..080a490 100644 --- a/src-tauri/tests/git.rs +++ b/src-tauri/tests/git.rs @@ -10,8 +10,8 @@ use gitmun_lib::git::handler::GitOperationHandler; use gitmun_lib::git::types::{ CommitDetailsRequest, CommitHistoryRequest, CommitLogScope, CommitRequest, CreateBranchRequest, ExportPatchFileSelection, ExportPatchRequest, ExportPatchScope, FileRequest, - ImportPatchRequest, PushFailureKind, PushRequest, RepoRequest, SetBranchUpstreamRequest, - StageFilesRequest, SubmoduleActionRequest, SubmoduleState, + ImportPatchRequest, PushFailureKind, PushRequest, RepoRequest, ResetMode, ResetRequest, + SetBranchUpstreamRequest, StageFilesRequest, SubmoduleActionRequest, SubmoduleState, }; fn init_repo() -> TempDir { @@ -602,6 +602,33 @@ fn unstage_file_in_repo_with_head_keeps_change_unstaged() { ); } +#[test] +fn hard_reset_to_head_discards_tracked_changes_and_keeps_untracked_files() { + let dir = init_repo(); + write_file(dir.path(), "tracked.txt", "v1"); + git(dir.path(), &["add", "tracked.txt"]); + git(dir.path(), &["commit", "-m", "add tracked file"]); + write_file(dir.path(), "tracked.txt", "v2"); + git(dir.path(), &["add", "tracked.txt"]); + write_file(dir.path(), "untracked.txt", "new"); + + let result = handler() + .reset(&ResetRequest { + repo_path: dir.path().to_str().unwrap().to_string(), + target: "HEAD".to_string(), + mode: ResetMode::Hard, + }) + .expect("hard reset"); + + assert_eq!(result.message, "Reset (hard) to HEAD"); + assert_eq!(read_file(dir.path(), "tracked.txt"), "v1"); + assert_eq!(read_file(dir.path(), "untracked.txt"), "new"); + assert_eq!( + git_stdout(dir.path(), &["status", "--porcelain"]), + "?? untracked.txt" + ); +} + #[test] fn status_detects_clean_submodule() { let (parent, _submodule) = repo_with_submodule(); @@ -819,6 +846,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/api/commands.ts b/src/api/commands.ts index 82ce5d3..616455e 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, @@ -67,6 +69,7 @@ import type { SetRemoteUrlRequest, PruneRemoteRequest, StashEntry, + CloneStartupOptions, ShellStartupAction, } from "../types"; @@ -339,7 +342,9 @@ export function revertAbort(repoPath: string): Promise { return invoke("revert_abort", {request: {repoPath}}); } -export function resetTo(repoPath: string, target: string, mode: "soft" | "mixed"): Promise { +export type ResetMode = "soft" | "mixed" | "hard"; + +export function resetTo(repoPath: string, target: string, mode: ResetMode): Promise { return invoke("reset", {request: {repoPath, target, mode}}); } @@ -459,6 +464,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"); } @@ -579,7 +596,7 @@ export function openSettingsWindow(): Promise { } export function openCloneWindow(): Promise { - return invoke("open_clone_window", {destination: null}); + return openCloneWindowWithOptions(); } export function openAboutWindow(): Promise { @@ -626,10 +643,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/ProjectView.tsx b/src/components/ProjectView.tsx index 2106a4d..e64f3dd 100644 --- a/src/components/ProjectView.tsx +++ b/src/components/ProjectView.tsx @@ -40,6 +40,7 @@ import { useGitTags } from "../hooks/useGitTags"; import { useGitRemotes } from "../hooks/useGitRemotes"; import { useGitStashes } from "../hooks/useGitStashes"; import * as api from "../api/commands"; +import type { ResetMode } from "../api/commands"; import type { CommitLogScope, CommitMarkers, @@ -1875,6 +1876,29 @@ export function ProjectView({ } }, [repoPath, refreshAll, showToast, t]); + const handleResetHead = useCallback(async (mode: Extract) => { + if (!repoPath) return; + const confirmed = await ask( + mode === "hard" ? t("ask.resetHead.hardMessage") : t("ask.resetHead.mixedMessage"), + { + title: mode === "hard" ? t("ask.resetHead.hardTitle") : t("ask.resetHead.mixedTitle"), + kind: "warning", + okLabel: t("actions.reset"), + cancelLabel: t("actions.cancel"), + }, + ); + if (!confirmed) return; + try { + const result = await api.resetTo(repoPath, "HEAD", mode); + showToast(result.message, "success"); + appendResultLog("success", result.message, result.backendUsed); + await refreshAll(); + } catch (e) { + showToast(String(e), "error"); + appendResultLog("error", t("log.resetFailed", { message: String(e) }), "unknown"); + } + }, [repoPath, refreshAll, showToast, t]); + const handleMergeConfirm = useCallback(async (strategy: MergeStrategy) => { if (!repoPath || !mergePendingBranch) return; setMergePendingBranch(null); @@ -2153,6 +2177,7 @@ export function ProjectView({ pushDisabled={remoteActionState.disabled} pushTitle={remoteActionTitle} onStash={handleStash} + onReset={handleResetHead} onImportPatch={handleImportPatch} onExportPatch={handleExportPatch} selectedPatchExportEnabled={selectedPatchFiles.length > 0} diff --git a/src/components/Titlebar.test.tsx b/src/components/Titlebar.test.tsx index 77b20db..f341406 100644 --- a/src/components/Titlebar.test.tsx +++ b/src/components/Titlebar.test.tsx @@ -40,6 +40,7 @@ function renderTitlebar( onImportPatch?: () => void; onExportPatch?: (scope: "staged" | "unstaged" | "all" | "selected") => void; selectedPatchExportEnabled?: boolean; + onReset?: (mode: "mixed" | "hard") => void; } = {}, ) { const onImportPatch = patchHandlers.onImportPatch ?? vi.fn(); @@ -70,6 +71,7 @@ function renderTitlebar( onPush={vi.fn()} pushLabel={pushLabel} onStash={vi.fn()} + onReset={patchHandlers.onReset ?? vi.fn()} onImportPatch={onImportPatch} onExportPatch={onExportPatch} selectedPatchExportEnabled={patchHandlers.selectedPatchExportEnabled ?? false} @@ -190,6 +192,46 @@ describe("Titlebar", () => { expect(screen.getByText("Export all changes patch...")).toBeInTheDocument(); }); + it("shows reset actions when a repository is open", () => { + renderTitlebar([makeBranch()]); + + fireEvent.click(screen.getByText("More")); + + expect(screen.getByText("Reset")).toBeInTheDocument(); + expect(screen.getByText("Unstage all changes...")).toBeInTheDocument(); + expect(screen.getByText("Discard tracked changes...")).toBeInTheDocument(); + }); + + it("calls reset with mixed mode from the more menu", () => { + const onReset = vi.fn(); + renderTitlebar([makeBranch()], "Push", "/repo", vi.fn(), { onReset }); + + fireEvent.click(screen.getByText("More")); + fireEvent.click(screen.getByText("Unstage all changes...")); + + expect(onReset).toHaveBeenCalledWith("mixed"); + }); + + it("calls reset with hard mode from the more menu", () => { + const onReset = vi.fn(); + renderTitlebar([makeBranch()], "Push", "/repo", vi.fn(), { onReset }); + + fireEvent.click(screen.getByText("More")); + fireEvent.click(screen.getByText("Discard tracked changes...")); + + expect(onReset).toHaveBeenCalledWith("hard"); + }); + + it("does not show reset actions when no repository is open", () => { + renderTitlebar([], "Push", null); + + fireEvent.click(screen.getByText("More")); + + expect(screen.queryByText("Reset")).not.toBeInTheDocument(); + expect(screen.queryByText("Unstage all changes...")).not.toBeInTheDocument(); + expect(screen.queryByText("Discard tracked changes...")).not.toBeInTheDocument(); + }); + it("disables selected patch export until files are checked", () => { renderTitlebar([makeBranch()]); diff --git a/src/components/Titlebar.tsx b/src/components/Titlebar.tsx index 361a4ac..016e4f3 100644 --- a/src/components/Titlebar.tsx +++ b/src/components/Titlebar.tsx @@ -6,6 +6,7 @@ import { MoreIcon, } from "./icons"; import * as api from "../api/commands"; +import type { ResetMode } from "../api/commands"; import type { PlatformType } from "../hooks/usePlatform"; import type { BranchInfo, RepoOpenLocation, RepoOpenLocationKind } from "../types"; import "./Titlebar.css"; @@ -38,6 +39,7 @@ type TitlebarProps = { pushDisabled?: boolean; pushTitle?: string; onStash: () => void; + onReset: (mode: Extract) => void; onImportPatch: () => void; onExportPatch: (scope: "staged" | "unstaged" | "all" | "selected") => void; selectedPatchExportEnabled: boolean; @@ -63,7 +65,7 @@ export function Titlebar({ identityInitials, identityAvatarUrl, recentRepos, searchQuery, searchInputRef, onSearchChange, onAboutClick, onSettingsClick, onIdentityClick, onCloneClick, onInitRepoClick, onOpenExistingClick, onRepoSelect, onOpenRepoLocation, onFetch, onPull, onPush, pushLabel, pushDisabled = false, pushTitle, onStash, - onImportPatch, onExportPatch, selectedPatchExportEnabled, + onReset, onImportPatch, onExportPatch, selectedPatchExportEnabled, identityOpen, remoteOp, }: TitlebarProps) { const { t } = useTranslation("titlebar"); @@ -159,6 +161,7 @@ export function Titlebar({ } label={t("actions.stash")} onClick={onStash} disabled={!repoPath} /> ) => void; onImportPatch: () => void; onExportPatch: (scope: "staged" | "unstaged" | "all" | "selected") => void; selectedPatchExportEnabled: boolean; @@ -304,6 +308,13 @@ function MoreDropdown({ repoPath, onImportPatch, onExportPatch, selectedPatchExp +
{t("reset.heading")}
+
run(() => onReset("mixed"))}> + {t("reset.mixed")} +
+
run(() => onReset("hard"))}> + {t("reset.hard")} +
)} 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" + />