diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3a9e171..26e9406 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -57,8 +57,13 @@ windows = { version = "0.61.3", features = [ "Foundation_Collections", "Services_Store", "Win32_Foundation", + "Win32_System_Com", + "Win32_System_Com_StructuredStorage", "Win32_System_Recovery", + "Win32_System_Variant", "Win32_UI_Shell", + "Win32_UI_Shell_Common", + "Win32_UI_Shell_PropertiesSystem", "Win32_UI_WindowsAndMessaging", ] } windows-future = "0.2.1" diff --git a/src-tauri/config.example.toml b/src-tauri/config.example.toml index 56468b6..42e5d3d 100644 --- a/src-tauri/config.example.toml +++ b/src-tauri/config.example.toml @@ -70,6 +70,10 @@ commitMessageRecommendedLength = 72 # Whether to automatically push tags when pushing commits. pushFollowTags = false +# Fetch remotes periodically while a repository window is focused. +# Use 0 to disable, or 5, 10, 30, or 60 minutes. +autoFetchIntervalMinutes = 0 + # Whether to check for application updates on launch. autoCheckForUpdatesOnLaunch = true diff --git a/src-tauri/src/commands/branches.rs b/src-tauri/src/commands/branches.rs index cf6cc1a..c37c77a 100644 --- a/src-tauri/src/commands/branches.rs +++ b/src-tauri/src/commands/branches.rs @@ -1,11 +1,12 @@ use crate::AppState; use crate::git::types::{ - AddRemoteRequest, BranchInfo, BranchRequest, CreateBranchRequest, CreateTagRequest, - DeleteBranchRequest, DeleteRemoteBranchRequest, DeleteRemoteTagRequest, DeleteTagRequest, - OperationResult, PruneRemoteRequest, PushTagRequest, RemoteInfo, RemoveRemoteRequest, - RenameBranchRequest, RenameRemoteRequest, RepoRequest, SetBranchUpstreamRequest, - SetRemoteUrlRequest, TagInfo, + AddRemoteRequest, BranchInfo, BranchRequest, CommitProgressEvent, CreateBranchRequest, + CreateTagRequest, DeleteBranchRequest, DeleteRemoteBranchRequest, DeleteRemoteTagRequest, + DeleteTagRequest, GitHookAttemptResult, OperationResult, PruneRemoteRequest, PushTagRequest, + RemoteInfo, RemoveRemoteRequest, RenameBranchRequest, RenameRemoteRequest, RepoRequest, + SetBranchUpstreamRequest, SetRemoteUrlRequest, TagInfo, }; +use std::sync::Arc; use tauri::Manager; #[tauri::command] @@ -22,14 +23,22 @@ pub async fn get_branches( } #[tauri::command] -pub fn switch_branch( +pub async fn switch_branch( request: BranchRequest, - state: tauri::State<'_, AppState>, -) -> Result { - state - .git_service - .switch_branch(request) - .map_err(|error| error.to_string()) + on_progress: tauri::ipc::Channel, + app: tauri::AppHandle, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + app.state::() + .git_service + .switch_branch_with_progress( + request, + Arc::new(move |event| drop(on_progress.send(event))), + ) + }) + .await + .map_err(|error| error.to_string())? + .map_err(|error| error.to_string()) } #[tauri::command] @@ -44,14 +53,22 @@ pub fn set_branch_upstream( } #[tauri::command] -pub fn create_branch( +pub async fn create_branch( request: CreateBranchRequest, - state: tauri::State<'_, AppState>, -) -> Result { - state - .git_service - .create_branch(request) - .map_err(|error| error.to_string()) + on_progress: tauri::ipc::Channel, + app: tauri::AppHandle, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + app.state::() + .git_service + .create_branch_with_progress( + request, + Arc::new(move |event| drop(on_progress.send(event))), + ) + }) + .await + .map_err(|error| error.to_string())? + .map_err(|error| error.to_string()) } #[tauri::command] @@ -109,36 +126,64 @@ pub fn create_tag( } #[tauri::command] -pub fn push_tag( +pub async fn push_tag( request: PushTagRequest, - state: tauri::State<'_, AppState>, -) -> Result { - state - .git_service - .push_tag(request) - .map_err(|e| e.to_string()) + skip_hooks: bool, + on_progress: tauri::ipc::Channel, + app: tauri::AppHandle, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + app.state::().git_service.push_tag_with_progress( + request, + skip_hooks, + Arc::new(move |event| drop(on_progress.send(event))), + ) + }) + .await + .map_err(|error| error.to_string())? + .map_err(|error| error.to_string()) } #[tauri::command] -pub fn delete_remote_tag( +pub async fn delete_remote_tag( request: DeleteRemoteTagRequest, - state: tauri::State<'_, AppState>, -) -> Result { - state - .git_service - .delete_remote_tag(request) - .map_err(|error| error.to_string()) + skip_hooks: bool, + on_progress: tauri::ipc::Channel, + app: tauri::AppHandle, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + app.state::() + .git_service + .delete_remote_tag_with_progress( + request, + skip_hooks, + Arc::new(move |event| drop(on_progress.send(event))), + ) + }) + .await + .map_err(|error| error.to_string())? + .map_err(|error| error.to_string()) } #[tauri::command] -pub fn delete_remote_branch( +pub async fn delete_remote_branch( request: DeleteRemoteBranchRequest, - state: tauri::State<'_, AppState>, -) -> Result { - state - .git_service - .delete_remote_branch(request) - .map_err(|error| error.to_string()) + skip_hooks: bool, + on_progress: tauri::ipc::Channel, + app: tauri::AppHandle, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + app.state::() + .git_service + .delete_remote_branch_with_progress( + request, + skip_hooks, + Arc::new(move |event| drop(on_progress.send(event))), + ) + }) + .await + .map_err(|error| error.to_string())? + .map_err(|error| error.to_string()) } #[tauri::command] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 81f3e1c..a74587d 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod branches; pub mod history; +pub mod recent_repositories; pub mod repo; pub mod settings; pub mod store_update; diff --git a/src-tauri/src/commands/recent_repositories.rs b/src-tauri/src/commands/recent_repositories.rs new file mode 100644 index 0000000..0f59feb --- /dev/null +++ b/src-tauri/src/commands/recent_repositories.rs @@ -0,0 +1,512 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecentRepositoriesSyncRequest { + pub paths: Vec, + pub category_label: String, + pub accessed_path: Option, + pub linux_seed_paths: Vec, +} + +#[cfg(any(target_os = "windows", test))] +#[derive(Debug, Clone, PartialEq, Eq)] +struct JumpListDestination { + path: String, + title: String, + arguments: String, +} + +#[cfg(any(target_os = "windows", test))] +#[derive(Debug, Clone, PartialEq, Eq)] +enum WindowsAppIdentity { + Packaged, + RunningProcess, +} + +#[cfg(any(target_os = "windows", test))] +fn windows_app_identity(is_msix_build: bool, has_package_identity: bool) -> WindowsAppIdentity { + if is_msix_build && has_package_identity { + WindowsAppIdentity::Packaged + } else { + WindowsAppIdentity::RunningProcess + } +} + +fn repository_title(path: &str) -> String { + path.trim_end_matches(['/', '\\']) + .rsplit(['/', '\\']) + .next() + .filter(|name| !name.is_empty()) + .unwrap_or(path) + .to_string() +} + +#[cfg(any(target_os = "windows", test))] +fn quote_windows_argument(argument: &str) -> String { + let mut quoted = String::from("\""); + let mut backslashes = 0; + for character in argument.chars() { + match character { + '\\' => backslashes += 1, + '"' => { + quoted.extend(std::iter::repeat_n('\\', backslashes * 2 + 1)); + quoted.push('"'); + backslashes = 0; + } + _ => { + quoted.extend(std::iter::repeat_n('\\', backslashes)); + quoted.push(character); + backslashes = 0; + } + } + } + quoted.extend(std::iter::repeat_n('\\', backslashes * 2)); + quoted.push('"'); + quoted +} + +#[cfg(any(target_os = "windows", test))] +fn jump_list_destinations( + paths: &[String], + removed_paths: &[String], + capacity: usize, +) -> Vec { + paths + .iter() + .filter(|path| !removed_paths.contains(path)) + .take(capacity) + .map(|path| JumpListDestination { + path: path.clone(), + title: format!("{} - {}", repository_title(path), path), + arguments: format!("--new-window open {}", quote_windows_argument(path)), + }) + .collect() +} + +#[cfg(any(target_os = "windows", test))] +trait JumpListWriter { + fn begin(&mut self) -> Result<(usize, Vec), String>; + fn append_category( + &mut self, + category_label: &str, + destinations: &[JumpListDestination], + ) -> Result<(), String>; + fn commit(&mut self) -> Result<(), String>; + fn abort(&mut self); +} + +#[cfg(any(target_os = "windows", test))] +fn rebuild_jump_list( + writer: &mut impl JumpListWriter, + paths: &[String], + category_label: &str, +) -> Result, String> { + let (capacity, removed_paths) = writer.begin()?; + let destinations = jump_list_destinations(paths, &removed_paths, capacity); + let result = writer + .append_category(category_label, &destinations) + .and_then(|()| writer.commit()); + if let Err(error) = result { + writer.abort(); + return Err(error); + } + Ok(removed_paths) +} + +#[tauri::command] +pub async fn sync_recent_repositories( + app: tauri::AppHandle, + request: RecentRepositoriesSyncRequest, +) -> Result, String> { + platform::sync(app, request).await +} + +#[cfg(target_os = "linux")] +mod platform { + use super::RecentRepositoriesSyncRequest; + use gtk::prelude::RecentManagerExt; + use std::path::Path; + + pub async fn sync( + app: tauri::AppHandle, + request: RecentRepositoriesSyncRequest, + ) -> Result, String> { + let mut accessed_paths = request.linux_seed_paths; + if let Some(accessed_path) = request.accessed_path { + accessed_paths.push(accessed_path); + } + if accessed_paths.is_empty() { + return Ok(Vec::new()); + } + + let (sender, receiver) = tokio::sync::oneshot::channel(); + app.run_on_main_thread(move || { + drop(sender.send(record_accesses(&accessed_paths))); + }) + .map_err(|error| error.to_string())?; + receiver.await.map_err(|error| error.to_string())??; + Ok(Vec::new()) + } + + fn record_accesses(paths: &[String]) -> Result<(), String> { + let manager = gtk::RecentManager::default() + .ok_or_else(|| "GTK recent manager is unavailable".to_string())?; + let executable = std::env::current_exe().map_err(|error| error.to_string())?; + let app_exec = format!( + "{} --new-window open %f", + gtk::glib::shell_quote(&executable).to_string_lossy() + ); + + for path in paths { + let uri = url::Url::from_directory_path(Path::new(path)) + .map_err(|()| format!("Cannot convert repository path to URI: {path}"))?; + let recent_data = gtk::RecentData { + display_name: Some(super::repository_title(path)), + description: Some(path.clone()), + mime_type: "inode/directory".to_string(), + app_name: "Gitmun".to_string(), + app_exec: app_exec.clone(), + groups: vec!["gitmun".to_string()], + is_private: false, + }; + if !manager.add_full(uri.as_str(), &recent_data) { + return Err(format!("GTK could not record recent repository: {path}")); + } + } + Ok(()) + } +} + +#[cfg(target_os = "windows")] +mod platform { + use super::{ + JumpListDestination, JumpListWriter, RecentRepositoriesSyncRequest, WindowsAppIdentity, + rebuild_jump_list, windows_app_identity, + }; + use windows::{ + Win32::{ + Foundation::PROPERTYKEY, + System::Com::{ + CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx, + CoTaskMemFree, CoUninitialize, StructuredStorage::PROPVARIANT, + }, + UI::Shell::{ + Common::{IObjectArray, IObjectCollection}, + DestinationList, EnumerableObjectCollection, + GetCurrentProcessExplicitAppUserModelID, ICustomDestinationList, IShellLinkW, + PropertiesSystem::IPropertyStore, + ShellLink, + }, + }, + core::{GUID, Interface, PCWSTR}, + }; + + const PKEY_TITLE: PROPERTYKEY = PROPERTYKEY { + fmtid: GUID::from_u128(0xf29f85e0_4ff9_1068_ab91_08002b27b3d9), + pid: 2, + }; + const SHELL_STRING_CAPACITY: usize = 32_768; + + pub async fn sync( + _app: tauri::AppHandle, + request: RecentRepositoriesSyncRequest, + ) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || sync_blocking(request)) + .await + .map_err(|error| error.to_string())? + } + + fn sync_blocking(request: RecentRepositoriesSyncRequest) -> Result, String> { + let _com = ComInitialisation::new()?; + let executable = std::env::current_exe().map_err(|error| error.to_string())?; + let identity = windows_app_identity(crate::is_msix_build(), crate::has_package_identity()); + let mut writer = WindowsJumpListWriter { + destination_list: None, + executable: executable.to_string_lossy().into_owned(), + identity, + }; + rebuild_jump_list(&mut writer, &request.paths, &request.category_label) + } + + struct ComInitialisation; + + impl ComInitialisation { + fn new() -> Result { + unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) } + .ok() + .map_err(|error| error.to_string())?; + Ok(Self) + } + } + + impl Drop for ComInitialisation { + fn drop(&mut self) { + unsafe { CoUninitialize() }; + } + } + + struct WindowsJumpListWriter { + destination_list: Option, + executable: String, + identity: WindowsAppIdentity, + } + + impl JumpListWriter for WindowsJumpListWriter { + fn begin(&mut self) -> Result<(usize, Vec), String> { + let destination_list: ICustomDestinationList = + unsafe { CoCreateInstance(&DestinationList, None, CLSCTX_INPROC_SERVER) } + .map_err(|error| error.to_string())?; + if self.identity == WindowsAppIdentity::RunningProcess { + if let Ok(app_id) = unsafe { GetCurrentProcessExplicitAppUserModelID() } { + let result = + unsafe { destination_list.SetAppID(PCWSTR::from_raw(app_id.as_ptr())) }; + unsafe { CoTaskMemFree(Some(app_id.as_ptr().cast())) }; + result.map_err(|error| error.to_string())?; + } + } + + let mut capacity = 0; + let removed: IObjectArray = unsafe { destination_list.BeginList(&mut capacity) } + .map_err(|error| error.to_string())?; + self.destination_list = Some(destination_list); + let removed_paths = match removed_paths(&removed) { + Ok(paths) => paths, + Err(error) => { + self.abort(); + return Err(error); + } + }; + Ok((capacity as usize, removed_paths)) + } + + fn append_category( + &mut self, + category_label: &str, + destinations: &[JumpListDestination], + ) -> Result<(), String> { + if destinations.is_empty() { + return Ok(()); + } + let collection: IObjectCollection = unsafe { + CoCreateInstance(&EnumerableObjectCollection, None, CLSCTX_INPROC_SERVER) + } + .map_err(|error| error.to_string())?; + for destination in destinations { + let link = self.create_link(destination)?; + unsafe { collection.AddObject(&link) }.map_err(|error| error.to_string())?; + } + let objects: IObjectArray = collection.cast().map_err(|error| error.to_string())?; + let category_label = wide_string(category_label); + unsafe { + self.destination_list() + .AppendCategory(PCWSTR::from_raw(category_label.as_ptr()), &objects) + } + .map_err(|error| error.to_string()) + } + + fn commit(&mut self) -> Result<(), String> { + unsafe { self.destination_list().CommitList() }.map_err(|error| error.to_string()) + } + + fn abort(&mut self) { + if let Some(destination_list) = &self.destination_list { + drop(unsafe { destination_list.AbortList() }); + } + } + } + + impl WindowsJumpListWriter { + fn destination_list(&self) -> &ICustomDestinationList { + self.destination_list + .as_ref() + .expect("destination list must be begun before it is updated") + } + + fn create_link(&self, destination: &JumpListDestination) -> Result { + let link: IShellLinkW = + unsafe { CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER) } + .map_err(|error| error.to_string())?; + let executable = wide_string(&self.executable); + let arguments = wide_string(&destination.arguments); + let description = wide_string(&destination.path); + let result = (|| -> windows::core::Result<()> { + unsafe { + link.SetPath(PCWSTR::from_raw(executable.as_ptr()))?; + link.SetArguments(PCWSTR::from_raw(arguments.as_ptr()))?; + link.SetDescription(PCWSTR::from_raw(description.as_ptr()))?; + link.SetIconLocation(PCWSTR::from_raw(executable.as_ptr()), 0)?; + let properties: IPropertyStore = link.cast()?; + let title = PROPVARIANT::from(destination.title.as_str()); + properties.SetValue(&PKEY_TITLE, &title)?; + properties.Commit()?; + } + Ok(()) + })(); + result.map_err(|error| error.to_string())?; + Ok(link) + } + } + + fn removed_paths(objects: &IObjectArray) -> Result, String> { + let count = unsafe { objects.GetCount() }.map_err(|error| error.to_string())?; + let mut paths = Vec::with_capacity(count as usize); + for index in 0..count { + let link: IShellLinkW = + unsafe { objects.GetAt(index) }.map_err(|error| error.to_string())?; + let mut description = vec![0_u16; SHELL_STRING_CAPACITY]; + unsafe { link.GetDescription(&mut description) }.map_err(|error| error.to_string())?; + let length = description + .iter() + .position(|character| *character == 0) + .unwrap_or(description.len()); + if length > 0 { + paths.push(String::from_utf16_lossy(&description[..length])); + } + } + Ok(paths) + } + + fn wide_string(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() + } +} + +#[cfg(not(any(target_os = "linux", target_os = "windows")))] +mod platform { + use super::RecentRepositoriesSyncRequest; + + pub async fn sync( + _app: tauri::AppHandle, + _request: RecentRepositoriesSyncRequest, + ) -> Result, String> { + Ok(Vec::new()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn limits_destinations_and_preserves_order() { + let paths = vec![ + r"C:\Repos\one".to_string(), + r"C:\Repos\two".to_string(), + r"C:\Repos\three".to_string(), + ]; + + let destinations = jump_list_destinations(&paths, &[], 2); + + assert_eq!( + destinations + .iter() + .map(|item| item.path.as_str()) + .collect::>(), + vec![r"C:\Repos\one", r"C:\Repos\two"] + ); + } + + #[test] + fn omits_removed_destinations_without_reordering_the_rest() { + let paths = vec![ + r"C:\Repos\one".to_string(), + r"C:\Repos\two".to_string(), + r"C:\Repos\three".to_string(), + ]; + + let destinations = jump_list_destinations(&paths, &[r"C:\Repos\two".to_string()], 10); + + assert_eq!( + destinations + .iter() + .map(|item| item.path.as_str()) + .collect::>(), + vec![r"C:\Repos\one", r"C:\Repos\three"] + ); + } + + #[test] + fn builds_unicode_titles_with_paths_and_correctly_quoted_arguments() { + let paths = vec![r#"C:\Repos\quoted name\résumé"#.to_string()]; + + let destinations = jump_list_destinations(&paths, &[], 10); + + assert_eq!( + destinations[0].title, + r#"résumé - C:\Repos\quoted name\résumé"# + ); + assert_eq!( + destinations[0].arguments, + r#"--new-window open "C:\Repos\quoted name\résumé""# + ); + assert_eq!( + quote_windows_argument(r#"C:\Repos\name"with quote\"#), + r#""C:\Repos\name\"with quote\\""# + ); + } + + #[test] + fn selects_packaged_and_running_process_identities() { + assert_eq!( + windows_app_identity(true, true), + WindowsAppIdentity::Packaged + ); + assert_eq!( + windows_app_identity(false, false), + WindowsAppIdentity::RunningProcess + ); + assert_eq!( + windows_app_identity(true, false), + WindowsAppIdentity::RunningProcess + ); + } + + #[derive(Default)] + struct TestWriter { + fail_append: bool, + committed: bool, + aborted: bool, + } + + impl JumpListWriter for TestWriter { + fn begin(&mut self) -> Result<(usize, Vec), String> { + Ok((10, Vec::new())) + } + + fn append_category( + &mut self, + _category_label: &str, + _destinations: &[JumpListDestination], + ) -> Result<(), String> { + if self.fail_append { + Err("append failed".to_string()) + } else { + Ok(()) + } + } + + fn commit(&mut self) -> Result<(), String> { + self.committed = true; + Ok(()) + } + + fn abort(&mut self) { + self.aborted = true; + } + } + + #[test] + fn aborts_without_committing_after_a_build_error() { + let mut writer = TestWriter { + fail_append: true, + ..TestWriter::default() + }; + + let result = rebuild_jump_list(&mut writer, &[r"C:\Repos\one".to_string()], "Recent"); + + assert_eq!(result, Err("append failed".to_string())); + assert!(writer.aborted); + assert!(!writer.committed); + } +} diff --git a/src-tauri/src/commands/repo.rs b/src-tauri/src/commands/repo.rs index e28534b..edeb0b2 100644 --- a/src-tauri/src/commands/repo.rs +++ b/src-tauri/src/commands/repo.rs @@ -1,13 +1,13 @@ use crate::git::types::{ - CloneRequest, CommitDetails, CommitDetailsRequest, CommitFileItem, CommitFilesRequest, - CommitMarkers, CommitMessageRecovery, CommitRequest, DiffRequest, ExportCommitPatchRequest, - ExportPatchRequest, ExternalDiffRequest, FetchRequest, FileDiff, FileRequest, GitIdentity, - HunkStageRequest, IdentityRequest, ImportPatchRequest, LocalCopyDestinationMode, - LocalCopyError, LocalCopyMode, LocalCopyProgress, LocalCopyProgressPhase, LocalCopyRequest, - LocalCopyResult, LocalCopyWarning, NumstatRequest, NumstatResult, OperationResult, - PullAnalysis, PullStrategyRequest, PushRequest, PushResult, RepoRequest, RepoStatus, - SetIdentityRequest, SshAllowedSignerStatus, StageFilesRequest, StashEntry, StashPushRequest, - StashRequest, SubmoduleActionRequest, + CloneRequest, CommitAttemptResult, CommitDetails, CommitDetailsRequest, CommitFileItem, + CommitFilesRequest, CommitMarkers, CommitMessageRecovery, CommitProgressEvent, CommitRequest, + DiffRequest, ExportCommitPatchRequest, ExportPatchRequest, ExternalDiffRequest, FetchRequest, + FileDiff, FileRequest, GitHookAttemptResult, GitIdentity, HunkStageRequest, IdentityRequest, + ImportPatchRequest, LocalCopyDestinationMode, LocalCopyError, LocalCopyMode, LocalCopyProgress, + LocalCopyProgressPhase, LocalCopyRequest, LocalCopyResult, LocalCopyWarning, NumstatRequest, + NumstatResult, OperationResult, PullAnalysis, PullStrategyRequest, PushRequest, PushResult, + RepoRequest, RepoStatus, SetIdentityRequest, SshAllowedSignerStatus, StageFilesRequest, + StashEntry, StashPushRequest, StashRequest, SubmoduleActionRequest, }; #[cfg(target_os = "linux")] use crate::git::types::{LINUX_TERMINAL_AUTO_ID, LINUX_TERMINAL_CUSTOM_ID}; @@ -2556,10 +2556,16 @@ pub async fn stage_files( #[tauri::command] pub async fn commit_changes( request: CommitRequest, + on_progress: tauri::ipc::Channel, app: tauri::AppHandle, -) -> Result { +) -> Result { tauri::async_runtime::spawn_blocking(move || { - app.state::().git_service.commit_changes(request) + app.state::() + .git_service + .commit_changes_with_progress( + request, + Arc::new(move |event| drop(on_progress.send(event))), + ) }) .await .map_err(|e| e.to_string())? @@ -2844,10 +2850,18 @@ pub fn add_ssh_signing_key_to_allowed_signers( #[tauri::command] pub async fn push_changes( request: PushRequest, + skip_hooks: bool, + on_progress: tauri::ipc::Channel, app: tauri::AppHandle, -) -> Result { +) -> Result, String> { tauri::async_runtime::spawn_blocking(move || { - app.state::().git_service.push_changes(request) + app.state::() + .git_service + .push_changes_with_progress( + request, + skip_hooks, + Arc::new(move |event| drop(on_progress.send(event))), + ) }) .await .map_err(|e| e.to_string())? diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 8b1a747..982b752 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -1141,6 +1141,16 @@ pub fn set_push_follow_tags(push_follow_tags: bool, state: tauri::State<'_, AppS state.git_service.set_push_follow_tags(push_follow_tags) } +#[tauri::command] +pub fn set_auto_fetch_interval_minutes( + auto_fetch_interval_minutes: u32, + state: tauri::State<'_, AppState>, +) -> Settings { + state + .git_service + .set_auto_fetch_interval_minutes(auto_fetch_interval_minutes) +} + #[tauri::command] pub fn set_commit_primary_action( commit_primary_action: CommitPrimaryAction, diff --git a/src-tauri/src/config_file.rs b/src-tauri/src/config_file.rs index 8ff2efa..165e209 100644 --- a/src-tauri/src/config_file.rs +++ b/src-tauri/src/config_file.rs @@ -13,6 +13,10 @@ pub fn load_or_migrate(toml_path: &Path, json_path: &Path) -> (Settings, bool) { match std::fs::read_to_string(toml_path) { Ok(text) => match toml::from_str::(&text) { Ok(mut settings) => { + settings.auto_fetch_interval_minutes = + Settings::normalised_auto_fetch_interval_minutes( + settings.auto_fetch_interval_minutes, + ); let migrated = settings.migrate_legacy_ai(contains_legacy_ai_keys(&text)); archive_migrated_json_config(json_path); return (settings, migrated); @@ -29,6 +33,8 @@ pub fn load_or_migrate(toml_path: &Path, json_path: &Path) -> (Settings, bool) { if json_path.exists() { let text = std::fs::read_to_string(json_path).unwrap_or_default(); let mut settings = serde_json::from_str::(&text).unwrap_or_default(); + settings.auto_fetch_interval_minutes = + Settings::normalised_auto_fetch_interval_minutes(settings.auto_fetch_interval_minutes); settings.migrate_legacy_ai(contains_legacy_ai_keys(&text)); let created = create_from_template(toml_path, &settings).is_ok(); @@ -278,6 +284,19 @@ mod tests { ); } + #[test] + fn load_toml_disables_unsupported_auto_fetch_interval() { + let dir = TempDir::new().unwrap(); + let toml_path = dir.path().join("config.toml"); + let json_path = dir.path().join("config.json"); + + write_file(&toml_path, "autoFetchIntervalMinutes = 1\n"); + + let (settings, should_persist) = load_or_migrate(&toml_path, &json_path); + assert!(!should_persist); + assert_eq!(settings.auto_fetch_interval_minutes, 0); + } + #[test] fn load_toml_normalises_ai_context_limits() { let dir = TempDir::new().unwrap(); diff --git a/src-tauri/src/git/cli.rs b/src-tauri/src/git/cli.rs index 9f8ae9c..a082e6d 100644 --- a/src-tauri/src/git/cli.rs +++ b/src-tauri/src/git/cli.rs @@ -1,10 +1,11 @@ use std::collections::{HashMap, HashSet}; use std::fs; -use std::io::Write; +use std::io::{Read, Write}; #[cfg(windows)] use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use std::sync::{Arc, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; use super::error::{GitError, GitResult}; @@ -12,13 +13,15 @@ use super::error_interpretation::{GitErrorCategory, InterpretedGitError, interpr use super::handler::GitOperationHandler; use super::types::{ AddRemoteRequest, BranchInfo, BranchRequest, CherryPickRequest, CherryPickResult, CloneRequest, - CommitDateMode, CommitDetails, CommitDetailsRequest, CommitFileItem, CommitFilesRequest, - CommitHistoryItem, CommitHistoryRequest, CommitLogScope, CommitMarkers, CommitMessageRecovery, - CommitRefDecoration, CommitRefKind, CommitRequest, ConflictFileItem, CreateBranchRequest, - CreateTagRequest, DeleteBranchRequest, DeleteRemoteBranchRequest, DeleteRemoteTagRequest, - DeleteTagRequest, DiffHunk, DiffLine, DiffLineKind, DiffRequest, ExportCommitPatchRequest, + CommitAttemptResult, CommitDateMode, CommitDetails, CommitDetailsRequest, CommitFileItem, + CommitFilesRequest, CommitHistoryItem, CommitHistoryRequest, CommitLogScope, CommitMarkers, + CommitMessageRecovery, CommitOutputStream, CommitProgressEvent, CommitRefDecoration, + CommitRefKind, CommitRequest, ConflictFileItem, CreateBranchRequest, CreateTagRequest, + DeleteBranchRequest, DeleteRemoteBranchRequest, DeleteRemoteTagRequest, DeleteTagRequest, + DiffHunk, DiffLine, DiffLineKind, DiffRequest, ExportCommitPatchRequest, ExportPatchFileSelection, ExportPatchRequest, ExportPatchScope, ExternalDiffRequest, - FetchRequest, FileDiff, FileRequest, FileStatusItem, GitIdentity, HunkStageRequest, + FetchRequest, FileDiff, FileRequest, FileStatusItem, GitHookAttemptResult, GitHookFailure, + GitIdentity, HunkStageRequest, IdentityRequest, IdentityScope, ImportPatchRequest, LineEndingStyle, MergeRequest, MergeResult, NumstatRequest, NumstatResult, OperationResult, PruneRemoteRequest, PullAnalysis, PullRecommendedAction, PullState, PullStrategy, PullStrategyRequest, PushFailureKind, @@ -33,6 +36,13 @@ use super::types::{ pub struct CliGitHandler; +struct HookCommandOutput { + status: std::process::ExitStatus, + output: Option, + output_truncated: bool, + hooks: Vec<(String, String, Option)>, +} + #[derive(Debug, Clone)] struct ConfiguredSubmodule { name: String, @@ -719,6 +729,323 @@ impl CliGitHandler { )) } + fn strip_terminal_controls(text: &str) -> String { + let mut cleaned = String::with_capacity(text.len()); + let mut chars = text.chars(); + while let Some(character) = chars.next() { + if character != '\u{1b}' { + cleaned.push(character); + continue; + } + + match chars.next() { + Some('[') => { + for next in chars.by_ref() { + if ('@'..='~').contains(&next) { + break; + } + } + } + Some(']') => { + let mut previous_was_escape = false; + for next in chars.by_ref() { + if next == '\u{7}' || (previous_was_escape && next == '\\') { + break; + } + previous_was_escape = next == '\u{1b}'; + } + } + Some(_) | None => {} + } + } + cleaned + } + + fn hooks_from_trace(trace_path: &Path) -> Vec<(String, String, Option)> { + let Ok(trace) = fs::read_to_string(trace_path) else { + return Vec::new(); + }; + let root_session_id = trace.lines().find_map(|line| { + serde_json::from_str::(line) + .ok()? + .get("sid")? + .as_str() + .map(str::to_string) + }); + let Some(root_session_id) = root_session_id else { + return Vec::new(); + }; + let mut hooks = Vec::new(); + let mut exit_codes = HashMap::new(); + for line in trace.lines() { + let Ok(event) = serde_json::from_str::(line) else { + continue; + }; + let session_id = event.get("sid").and_then(serde_json::Value::as_str); + if session_id != Some(root_session_id.as_str()) { + continue; + } + let child_id = event.get("child_id").and_then(serde_json::Value::as_u64); + match event.get("event").and_then(serde_json::Value::as_str) { + Some("child_start") + if event.get("child_class").and_then(serde_json::Value::as_str) + == Some("hook") => + { + if let (Some(child_id), Some(hook_name)) = ( + child_id, + event.get("hook_name").and_then(serde_json::Value::as_str), + ) { + hooks.push((child_id, hook_name.to_string())); + } + } + Some("child_exit") => { + if let (Some(child_id), Some(code)) = ( + child_id, + event.get("code").and_then(serde_json::Value::as_i64), + ) { + exit_codes.insert(child_id, code as i32); + } + } + _ => {} + } + } + hooks + .into_iter() + .map(|(child_id, hook_name)| { + ( + format!("{root_session_id}:{child_id}"), + hook_name, + exit_codes.get(&child_id).copied(), + ) + }) + .collect() + } + + fn run_git_with_hook_progress( + repo_path: &Path, + args: &[String], + on_progress: Arc, + ) -> GitResult { + const MAX_CAPTURED_OUTPUT_BYTES: usize = 1024 * 1024; + + let git_dir = Self::run_git(&["rev-parse", "--absolute-git-dir"], Some(repo_path))?; + let trace = tempfile::NamedTempFile::new_in(Path::new(&git_dir))?; + let mut command = + crate::git_command_with_environment(&[("GIT_TRACE2_EVENT", trace.path().as_os_str())]); + Self::configure_command(&mut command); + command + .args(args) + .current_dir(repo_path) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = command.spawn()?; + let stdout = child.stdout.take().expect("Git command stdout is piped"); + let stderr = child.stderr.take().expect("Git command stderr is piped"); + let captured = Arc::new(Mutex::new((String::new(), false))); + let spawn_reader = |reader: Box, stream: CommitOutputStream| { + let captured = Arc::clone(&captured); + let on_progress = Arc::clone(&on_progress); + std::thread::spawn(move || { + let mut reader = std::io::BufReader::new(reader); + let mut buffer = [0_u8; 4096]; + loop { + let bytes_read = match reader.read(&mut buffer) { + Ok(0) | Err(_) => break, + Ok(bytes_read) => bytes_read, + }; + let text = Self::strip_terminal_controls(&String::from_utf8_lossy( + &buffer[..bytes_read], + )); + if text.is_empty() { + continue; + } + let mut emitted = None; + let mut truncation_marker = false; + if let Ok(mut output) = captured.lock() { + let remaining = MAX_CAPTURED_OUTPUT_BYTES.saturating_sub(output.0.len()); + if remaining == 0 { + truncation_marker = !output.1; + output.1 = true; + } else if text.len() > remaining { + let mut end = remaining; + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + let accepted = text[..end].to_string(); + output.0.push_str(&accepted); + emitted = Some(accepted); + truncation_marker = !output.1; + output.1 = true; + } else { + output.0.push_str(&text); + emitted = Some(text); + } + } + if let Some(text) = emitted { + on_progress(CommitProgressEvent::Output { + stream: stream.clone(), + text, + truncated: false, + }); + } + if truncation_marker { + on_progress(CommitProgressEvent::Output { + stream: stream.clone(), + text: String::new(), + truncated: true, + }); + } + } + }) + }; + let stdout_reader = spawn_reader(Box::new(stdout), CommitOutputStream::Stdout); + let stderr_reader = spawn_reader(Box::new(stderr), CommitOutputStream::Stderr); + let mut announced_hooks = HashSet::new(); + let mut finished_hooks = HashSet::new(); + let status = loop { + for (key, hook_name, hook_exit_status) in Self::hooks_from_trace(trace.path()) { + if announced_hooks.insert(key.clone()) { + on_progress(CommitProgressEvent::HookStarted { + hook_name: hook_name.clone(), + }); + } + if let Some(exit_status) = hook_exit_status + && finished_hooks.insert(key) + { + on_progress(CommitProgressEvent::HookFinished { + hook_name, + exit_status: Some(exit_status), + }); + } + } + if let Some(status) = child.try_wait()? { + break status; + } + std::thread::sleep(std::time::Duration::from_millis(25)); + }; + drop(stdout_reader.join()); + drop(stderr_reader.join()); + let hooks = Self::hooks_from_trace(trace.path()); + for (key, hook_name, hook_exit_status) in &hooks { + if announced_hooks.insert(key.clone()) { + on_progress(CommitProgressEvent::HookStarted { + hook_name: hook_name.clone(), + }); + } + if let Some(exit_status) = hook_exit_status + && finished_hooks.insert(key.clone()) + { + on_progress(CommitProgressEvent::HookFinished { + hook_name: hook_name.clone(), + exit_status: Some(*exit_status), + }); + } + } + let (output, output_truncated) = captured + .lock() + .map(|output| output.clone()) + .unwrap_or_default(); + Ok(HookCommandOutput { + status, + output: (!output.trim().is_empty()).then_some(output), + output_truncated, + hooks, + }) + } + + pub fn commit_changes_with_progress( + &self, + request: &CommitRequest, + on_progress: Arc, + ) -> GitResult { + let repo_path = Self::normalise_repo_path(&request.repo_path)?; + let message = request.message.trim(); + if message.is_empty() { + return Err(GitError::InvalidInput( + "Commit message cannot be empty".to_string(), + )); + } + + 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".to_string(), "--file".to_string(), message_file]; + if request.skip_hooks { + args.push("--no-verify".to_string()); + } + if request.amend == Some(true) { + args.push("--amend".to_string()); + } + let commit_gpgsign = Self::run_git_allow_exit_codes( + &["config", "--get", "commit.gpgsign"], + Some(&repo_path), + &[1], + ) + .ok() + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()); + let should_sign = match commit_gpgsign.as_deref() { + Some("false") | Some("0") | Some("no") | Some("off") => false, + Some("true") | Some("1") | Some("yes") | Some("on") => true, + Some(_) => true, + None => false, + }; + if should_sign { + #[cfg(windows)] + { + let signing_format = Self::run_git_allow_exit_codes( + &["config", "--get", "gpg.format"], + Some(&repo_path), + &[1], + ) + .ok() + .map(|value| value.trim().to_ascii_lowercase()); + if !matches!(signing_format.as_deref(), Some("ssh")) { + crate::ensure_windows_gpg_program_configured(Some(&repo_path)) + .map_err(GitError::InvalidInput)?; + } + } + args.push("-S".to_string()); + } + let outcome = Self::run_git_with_hook_progress(&repo_path, &args, on_progress)?; + drop(fs::remove_file(&message_file_path)); + if outcome.status.success() { + return Ok(CommitAttemptResult::Committed { + result: OperationResult { + message: format!("Committed changes in {}", repo_path.display()), + output: outcome.output, + repo_path: Some(Self::path_to_string(&repo_path)), + backend_used: "git-cli".to_string(), + interpreted_error: None, + }, + output_truncated: outcome.output_truncated, + }); + } + + let rejected_hook = outcome + .hooks + .into_iter() + .rev() + .find(|(_, _, hook_exit_status)| hook_exit_status.is_some_and(|code| code != 0)); + if let Some((_, hook_name, hook_exit_status)) = rejected_hook { + let bypass_supported = matches!(hook_name.as_str(), "pre-commit" | "commit-msg"); + return Ok(CommitAttemptResult::HookRejected { + hook_name, + exit_status: hook_exit_status, + output: outcome.output, + output_truncated: outcome.output_truncated, + bypass_supported, + }); + } + + Err(GitError::CommandFailed { + command: "git commit".to_string(), + stderr: outcome.output.unwrap_or_default(), + exit_code: outcome.status.code(), + }) + } + fn clean_commit_edit_message(message: &str) -> String { message .lines() @@ -2337,6 +2664,353 @@ impl CliGitHandler { )?; Ok(!output.trim().is_empty()) } + + fn hook_failure( + outcome: &HookCommandOutput, + hook_name: &str, + bypass_supported: bool, + ) -> Option { + outcome + .hooks + .iter() + .rev() + .find(|(_, name, status)| name == hook_name && status.is_some_and(|code| code != 0)) + .map(|(_, name, status)| GitHookFailure { + hook_name: name.clone(), + exit_status: *status, + output: outcome.output.clone(), + output_truncated: outcome.output_truncated, + bypass_supported, + }) + } + + fn completed_hook_operation( + repo_path: &Path, + args: Vec, + message: String, + warning_hook: Option<&str>, + on_progress: Arc, + ) -> GitResult> { + let outcome = Self::run_git_with_hook_progress(repo_path, &args, on_progress)?; + let hook_warning = warning_hook.and_then(|name| Self::hook_failure(&outcome, name, false)); + if !outcome.status.success() && hook_warning.is_none() { + return Err(GitError::CommandFailed { + command: args.join(" "), + stderr: outcome.output.unwrap_or_default(), + exit_code: outcome.status.code(), + }); + } + Ok(GitHookAttemptResult::Completed { + result: OperationResult { + message, + output: outcome.output, + repo_path: Some(Self::path_to_string(repo_path)), + backend_used: "git-cli".to_string(), + interpreted_error: None, + }, + hook_warning, + output_truncated: outcome.output_truncated, + }) + } + + fn push_hook_operation( + repo_path: &Path, + mut args: Vec, + message: String, + skip_hooks: bool, + on_progress: Arc, + ) -> GitResult> { + if skip_hooks { + args.insert(1, "--no-verify".to_string()); + } + let outcome = Self::run_git_with_hook_progress(repo_path, &args, on_progress)?; + if let Some(failure) = Self::hook_failure(&outcome, "pre-push", true) { + return Ok(GitHookAttemptResult::HookRejected { + hook_name: failure.hook_name, + exit_status: failure.exit_status, + output: failure.output, + output_truncated: failure.output_truncated, + bypass_supported: failure.bypass_supported, + }); + } + if !outcome.status.success() { + return Err(GitError::CommandFailed { + command: args.join(" "), + stderr: outcome.output.unwrap_or_default(), + exit_code: outcome.status.code(), + }); + } + Ok(GitHookAttemptResult::Completed { + result: OperationResult { + message, + output: outcome.output, + repo_path: Some(Self::path_to_string(repo_path)), + backend_used: "git-cli".to_string(), + interpreted_error: None, + }, + hook_warning: None, + output_truncated: outcome.output_truncated, + }) + } + + pub fn push_tag_with_progress( + &self, + request: &PushTagRequest, + skip_hooks: bool, + on_progress: Arc, + ) -> GitResult> { + let repo_path = Self::normalise_repo_path(&request.repo_path)?; + let remote = request.remote.trim(); + let tag_name = request.tag_name.trim(); + Self::ensure_valid_remote_name(&repo_path, remote)?; + Self::ensure_valid_tag_name(&repo_path, tag_name)?; + Self::push_hook_operation( + &repo_path, + vec!["push".to_string(), remote.to_string(), tag_name.to_string()], + format!("Pushed tag '{tag_name}' to '{remote}'"), + skip_hooks, + on_progress, + ) + } + + pub fn delete_remote_tag_with_progress( + &self, + request: &DeleteRemoteTagRequest, + skip_hooks: bool, + on_progress: Arc, + ) -> GitResult> { + let repo_path = Self::normalise_repo_path(&request.repo_path)?; + let remote = request.remote.trim(); + let tag_name = request.tag_name.trim(); + Self::ensure_valid_remote_name(&repo_path, remote)?; + Self::ensure_valid_tag_name(&repo_path, tag_name)?; + Self::push_hook_operation( + &repo_path, + vec!["push".to_string(), remote.to_string(), "--delete".to_string(), tag_name.to_string()], + format!("Deleted tag '{tag_name}' from remote '{remote}'"), + skip_hooks, + on_progress, + ) + } + + pub fn delete_remote_branch_with_progress( + &self, + request: &DeleteRemoteBranchRequest, + skip_hooks: bool, + on_progress: Arc, + ) -> GitResult> { + let repo_path = Self::normalise_repo_path(&request.repo_path)?; + let remote = request.remote.trim(); + let branch = request.branch.trim(); + if branch.is_empty() { + return Err(GitError::InvalidInput("Branch name cannot be empty".to_string())); + } + Self::ensure_valid_remote_name(&repo_path, remote)?; + match Self::push_hook_operation( + &repo_path, + vec!["push".to_string(), remote.to_string(), format!(":{branch}")], + format!("Deleted remote branch '{remote}/{branch}'"), + skip_hooks, + on_progress, + ) { + Err(GitError::CommandFailed { ref stderr, .. }) + if stderr.contains("remote ref does not exist") => + { + Err(GitError::InvalidInput(format!( + "Branch '{branch}' no longer exists on remote '{remote}'. Try fetching to refresh the branch list." + ))) + } + result => result, + } + } + + pub fn switch_branch_with_progress( + &self, + request: &BranchRequest, + on_progress: Arc, + ) -> GitResult> { + let repo_path = Self::normalise_repo_path(&request.repo_path)?; + let branch_name = request.branch_name.trim(); + if branch_name.is_empty() { + return Err(GitError::InvalidInput("Branch name cannot be empty".to_string())); + } + Self::ensure_no_active_branch_operation(&repo_path, "switch branches")?; + let args = vec!["switch".to_string(), branch_name.to_string()]; + let outcome = Self::run_git_with_hook_progress(&repo_path, &args, Arc::clone(&on_progress))?; + let unavailable = !outcome.status.success() + && outcome.hooks.is_empty() + && outcome.output.as_deref().is_some_and(|output| output.contains("'switch' is not a git command")); + if unavailable { + return Self::completed_hook_operation( + &repo_path, + vec!["checkout".to_string(), branch_name.to_string()], + format!("Switched to branch '{branch_name}'"), + Some("post-checkout"), + on_progress, + ); + } + let hook_warning = Self::hook_failure(&outcome, "post-checkout", false); + if !outcome.status.success() && hook_warning.is_none() { + return Err(GitError::CommandFailed { + command: args.join(" "), + stderr: outcome.output.unwrap_or_default(), + exit_code: outcome.status.code(), + }); + } + Ok(GitHookAttemptResult::Completed { + result: OperationResult { + message: format!("Switched to branch '{branch_name}'"), + output: outcome.output, + repo_path: Some(Self::path_to_string(&repo_path)), + backend_used: "git-cli".to_string(), + interpreted_error: None, + }, + hook_warning, + output_truncated: outcome.output_truncated, + }) + } + + pub fn push_changes_with_progress( + &self, + request: &PushRequest, + skip_hooks: bool, + on_progress: Arc, + ) -> GitResult> { + let repo_path = Self::normalise_repo_path(&request.repo_path)?; + let current_branch = Self::current_branch_name(&repo_path) + .filter(|branch| !Self::is_detached_head(Some(branch))) + .ok_or_else(|| GitError::InvalidInput("Push is unavailable while HEAD is detached.".to_string()))?; + let remote = request.remote.as_deref().map(str::trim).filter(|value| !value.is_empty()); + let explicit_remote_branch = request.remote_branch.as_deref().map(str::trim).filter(|value| !value.is_empty()); + if remote.is_none() && (request.set_upstream || explicit_remote_branch.is_some()) { + return Err(GitError::InvalidInput("Publishing requires an explicit remote selection.".to_string())); + } + let target_remote_branch = match (remote, explicit_remote_branch) { + (Some(_), Some(branch)) => Some(branch.to_string()), + (Some(_), None) => Some(current_branch.clone()), + (None, Some(_)) => unreachable!(), + (None, None) => None, + }; + let mut args = vec!["push".to_string()]; + if skip_hooks { args.push("--no-verify".to_string()); } + if request.force_with_lease { args.push("--force-with-lease".to_string()); } + if request.push_follow_tags { args.push("--follow-tags".to_string()); } + if request.set_upstream { args.push("--set-upstream".to_string()); } + let refspec = match (remote, target_remote_branch.as_deref()) { + (Some(_), Some(target_branch)) if target_branch != current_branch => Some(format!("{current_branch}:{target_branch}")), + (Some(_), Some(_)) => Some(current_branch.clone()), + _ => None, + }; + if let Some(remote_name) = remote { + args.push(remote_name.to_string()); + if let Some(target_refspec) = refspec { args.push(target_refspec); } + } + let outcome = Self::run_git_with_hook_progress(&repo_path, &args, on_progress)?; + if let Some(failure) = Self::hook_failure(&outcome, "pre-push", true) { + return Ok(GitHookAttemptResult::HookRejected { + hook_name: failure.hook_name, + exit_status: failure.exit_status, + output: failure.output, + output_truncated: failure.output_truncated, + bypass_supported: true, + }); + } + let message = match (remote, target_remote_branch.as_deref(), request.set_upstream) { + (Some(remote_name), Some(target_branch), true) => format!("Published branch to {remote_name}/{target_branch}"), + (Some(remote_name), Some(target_branch), false) => format!("Pushed changes to {remote_name}/{target_branch}"), + _ => "Pushed changes".to_string(), + }; + let result = if outcome.status.success() { + PushResult { + message, + output: outcome.output, + repo_path: Some(Self::path_to_string(&repo_path)), + backend_used: "git-cli".to_string(), + interpreted_error: None, + success: true, + rejection: None, + } + } else { + let stderr = outcome.output.unwrap_or_default(); + let interpreted = Self::interpret_push_failure(&stderr, outcome.status.code()); + let rejection = Self::classify_push_failure(&repo_path, &stderr); + PushResult { + message: rejection.message.clone(), + output: (!stderr.is_empty()).then_some(stderr), + repo_path: Some(Self::path_to_string(&repo_path)), + backend_used: "git-cli".to_string(), + interpreted_error: Some(interpreted), + success: false, + rejection: Some(rejection), + } + }; + Ok(GitHookAttemptResult::Completed { + result, + hook_warning: None, + output_truncated: outcome.output_truncated, + }) + } + + pub fn create_branch_with_progress( + &self, + request: &CreateBranchRequest, + on_progress: Arc, + ) -> GitResult> { + if request.checkout_after_creation != Some(true) { + return self.create_branch(request).map(|result| GitHookAttemptResult::Completed { + result, + hook_warning: None, + output_truncated: false, + }); + } + let repo_path = Self::normalise_repo_path(&request.repo_path)?; + let mut branch_name = request.branch_name.trim().to_string(); + let base_ref = request.base_ref.as_deref().map(str::trim).filter(|value| !value.is_empty()); + let remote_tracking_ref = base_ref.and_then(|reference| Self::resolve_remote_tracking_ref(&repo_path, reference)); + if request.match_tracking_branch.unwrap_or(false) { + let remote_ref = remote_tracking_ref.as_deref().ok_or_else(|| GitError::InvalidInput( + "Match tracking branch name requires a remote branch base reference".to_string(), + ))?; + branch_name = Self::derive_local_branch_from_remote_ref(remote_ref).ok_or_else(|| GitError::InvalidInput( + "Cannot derive a local branch name from this remote reference".to_string(), + ))?; + } + if branch_name.trim().is_empty() { + return Err(GitError::InvalidInput("Branch name cannot be empty".to_string())); + } + Self::ensure_valid_branch_name(&repo_path, &branch_name)?; + let mut args = vec!["checkout".to_string(), "-b".to_string(), branch_name.clone()]; + if let Some(base_ref) = base_ref { args.push(base_ref.to_string()); } + let outcome = Self::run_git_with_hook_progress(&repo_path, &args, on_progress)?; + let hook_warning = Self::hook_failure(&outcome, "post-checkout", false); + if !outcome.status.success() && hook_warning.is_none() { + return Err(GitError::CommandFailed { + command: args.join(" "), + stderr: outcome.output.unwrap_or_default(), + exit_code: outcome.status.code(), + }); + } + let mut message = format!("Created branch '{branch_name}'"); + if request.track_remote.unwrap_or(false) { + let remote_branch = remote_tracking_ref.as_deref().ok_or_else(|| GitError::InvalidInput( + "Tracking requires a valid remote branch base reference".to_string(), + ))?; + Self::run_git(&["branch", "--set-upstream-to", remote_branch, &branch_name], Some(&repo_path))?; + message.push_str(&format!(" and set to track '{remote_branch}'")); + } + message.push_str(" and checked out"); + Ok(GitHookAttemptResult::Completed { + result: OperationResult { + message, + output: outcome.output, + repo_path: Some(Self::path_to_string(&repo_path)), + backend_used: "git-cli".to_string(), + interpreted_error: None, + }, + hook_warning, + output_truncated: outcome.output_truncated, + }) + } } impl GitOperationHandler for CliGitHandler { @@ -2522,6 +3196,9 @@ impl GitOperationHandler for CliGitHandler { if request.amend == Some(true) { args.push("--amend"); } + if request.skip_hooks { + args.push("--no-verify"); + } let output = Self::run_git(&args, Some(&repo_path)); let _ = fs::remove_file(&message_file_path); let output = output?; diff --git a/src-tauri/src/git/handler.rs b/src-tauri/src/git/handler.rs index cbb95cb..71c8d89 100644 --- a/src-tauri/src/git/handler.rs +++ b/src-tauri/src/git/handler.rs @@ -6,19 +6,19 @@ use super::error::GitResult; use super::gix_handler::GixGitHandler; use super::types::{ AddRemoteRequest, BackendMode, BranchInfo, BranchRequest, CherryPickRequest, CherryPickResult, - CloneRequest, CommitDateMode, CommitDetails, CommitDetailsRequest, CommitFileItem, - CommitFilesRequest, CommitHistoryItem, CommitHistoryRequest, CommitMarkers, - CommitMessageRecovery, CommitPrimaryAction, CommitRequest, CreateBranchRequest, - CreateTagRequest, DeleteBranchRequest, DeleteRemoteBranchRequest, DeleteRemoteTagRequest, - DeleteTagRequest, DiffRequest, ExportCommitPatchRequest, ExportPatchRequest, - ExternalDiffRequest, FetchRequest, FileDiff, FileRequest, GitIdentity, HunkStageRequest, - IdentityRequest, ImportPatchRequest, MergeRequest, MergeResult, NumstatRequest, NumstatResult, - OperationResult, PruneRemoteRequest, PullAnalysis, PullStrategyRequest, PushRequest, - PushResult, PushTagRequest, RebaseRequest, RebaseResult, RemoteInfo, RemoveRemoteRequest, - RenameBranchRequest, RenameRemoteRequest, RepoRequest, RepoStatus, ResetRequest, - RevertCommitRequest, SetBranchUpstreamRequest, SetIdentityRequest, SetRemoteUrlRequest, - Settings, SshAllowedSignerStatus, StageFilesRequest, StashEntry, StashPushRequest, - StashRequest, SubmoduleActionRequest, TagInfo, ThemeMode, + CloneRequest, CommitAttemptResult, CommitDateMode, CommitDetails, CommitDetailsRequest, + CommitFileItem, CommitFilesRequest, CommitHistoryItem, CommitHistoryRequest, CommitMarkers, + CommitMessageRecovery, CommitPrimaryAction, CommitProgressEvent, CommitRequest, + CreateBranchRequest, CreateTagRequest, DeleteBranchRequest, DeleteRemoteBranchRequest, + DeleteRemoteTagRequest, DeleteTagRequest, DiffRequest, ExportCommitPatchRequest, + ExportPatchRequest, ExternalDiffRequest, FetchRequest, FileDiff, FileRequest, + GitHookAttemptResult, GitIdentity, HunkStageRequest, IdentityRequest, ImportPatchRequest, + MergeRequest, MergeResult, NumstatRequest, NumstatResult, OperationResult, PruneRemoteRequest, + PullAnalysis, PullStrategyRequest, PushRequest, PushResult, PushTagRequest, RebaseRequest, + RebaseResult, RemoteInfo, RemoveRemoteRequest, RenameBranchRequest, RenameRemoteRequest, + RepoRequest, RepoStatus, ResetRequest, RevertCommitRequest, SetBranchUpstreamRequest, + SetIdentityRequest, SetRemoteUrlRequest, Settings, SshAllowedSignerStatus, StageFilesRequest, + StashEntry, StashPushRequest, StashRequest, SubmoduleActionRequest, TagInfo, ThemeMode, }; pub trait GitOperationHandler: Send + Sync { @@ -146,7 +146,7 @@ pub struct GitService { settings: RwLock, config_path: RwLock>, gix_handler: Arc, - cli_handler: Arc, + cli_handler: Arc, } impl GitService { @@ -335,6 +335,13 @@ impl GitService { }) } + pub fn set_auto_fetch_interval_minutes(&self, minutes: u32) -> Settings { + self.update_settings(|settings| { + settings.auto_fetch_interval_minutes = + Settings::normalised_auto_fetch_interval_minutes(minutes); + }) + } + pub fn set_commit_primary_action( &self, commit_primary_action: CommitPrimaryAction, @@ -615,12 +622,12 @@ impl GitService { match mode { BackendMode::Default => Arc::clone(&self.gix_handler), - BackendMode::GitCliOnly => Arc::clone(&self.cli_handler), + BackendMode::GitCliOnly => self.cli_handler.clone(), } } fn active_write_handler(&self) -> Arc { - Arc::clone(&self.cli_handler) + self.cli_handler.clone() } #[allow(dead_code)] @@ -644,6 +651,73 @@ impl GitService { .get_commit_message_recovery(&request) } + pub fn commit_changes_with_progress( + &self, + request: CommitRequest, + on_progress: Arc, + ) -> GitResult { + self.cli_handler + .commit_changes_with_progress(&request, on_progress) + } + + pub fn push_changes_with_progress( + &self, + request: PushRequest, + skip_hooks: bool, + on_progress: Arc, + ) -> GitResult> { + self.cli_handler + .push_changes_with_progress(&request, skip_hooks, on_progress) + } + + pub fn switch_branch_with_progress( + &self, + request: BranchRequest, + on_progress: Arc, + ) -> GitResult> { + self.cli_handler + .switch_branch_with_progress(&request, on_progress) + } + + pub fn create_branch_with_progress( + &self, + request: CreateBranchRequest, + on_progress: Arc, + ) -> GitResult> { + self.cli_handler + .create_branch_with_progress(&request, on_progress) + } + + pub fn push_tag_with_progress( + &self, + request: PushTagRequest, + skip_hooks: bool, + on_progress: Arc, + ) -> GitResult> { + self.cli_handler + .push_tag_with_progress(&request, skip_hooks, on_progress) + } + + pub fn delete_remote_tag_with_progress( + &self, + request: DeleteRemoteTagRequest, + skip_hooks: bool, + on_progress: Arc, + ) -> GitResult> { + self.cli_handler + .delete_remote_tag_with_progress(&request, skip_hooks, on_progress) + } + + pub fn delete_remote_branch_with_progress( + &self, + request: DeleteRemoteBranchRequest, + skip_hooks: bool, + on_progress: Arc, + ) -> GitResult> { + self.cli_handler + .delete_remote_branch_with_progress(&request, skip_hooks, on_progress) + } + forward_write_methods! { fn analyze_pull(request: RepoRequest) -> GitResult; fn pull_changes(request: RepoRequest) -> GitResult; @@ -765,6 +839,17 @@ mod tests { assert!(service.get_settings().enable_local_copy); } + #[test] + fn auto_fetch_interval_setter_disables_unsupported_values() { + let service = GitService::new(); + + let settings = service.set_auto_fetch_interval_minutes(5); + assert_eq!(settings.auto_fetch_interval_minutes, 5); + + let settings = service.set_auto_fetch_interval_minutes(1); + assert_eq!(settings.auto_fetch_interval_minutes, 0); + } + #[test] fn ai_context_limit_setters_normalise_values() { let service = GitService::new(); diff --git a/src-tauri/src/git/types.rs b/src-tauri/src/git/types.rs index 085b699..09c6dd9 100644 --- a/src-tauri/src/git/types.rs +++ b/src-tauri/src/git/types.rs @@ -316,6 +316,10 @@ pub struct Settings { pub commit_message_recommended_length: u32, #[serde(default)] pub push_follow_tags: bool, + /// How often an open, focused repository is fetched automatically. + /// Zero disables automatic fetching. + #[serde(default)] + pub auto_fetch_interval_minutes: u32, #[serde(default = "Settings::default_auto_check_for_updates_on_launch")] pub auto_check_for_updates_on_launch: bool, #[serde(default)] @@ -421,6 +425,7 @@ impl Default for Settings { commit_primary_action: CommitPrimaryAction::Commit, commit_message_recommended_length: 72, push_follow_tags: false, + auto_fetch_interval_minutes: 0, auto_check_for_updates_on_launch: true, auto_install_updates: false, update_endpoint: Self::default_update_endpoint(), @@ -447,6 +452,14 @@ impl Default for Settings { } impl Settings { + pub fn normalised_auto_fetch_interval_minutes(value: u32) -> u32 { + if [0, 5, 10, 30, 60].contains(&value) { + value + } else { + 0 + } + } + pub fn normalised_ui_text_scale(value: f64) -> f64 { normalise_ui_text_scale(value) } @@ -706,6 +719,129 @@ pub struct CommitRequest { pub repo_path: String, pub message: String, pub amend: Option, + #[serde(default)] + pub skip_hooks: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde( + rename_all = "camelCase", + rename_all_fields = "camelCase", + tag = "event" +)] +pub enum CommitProgressEvent { + Output { + stream: CommitOutputStream, + text: String, + truncated: bool, + }, + HookStarted { + hook_name: String, + }, + HookFinished { + hook_name: String, + exit_status: Option, + }, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum CommitOutputStream { + Stdout, + Stderr, +} + +#[derive(Debug, Clone, Serialize)] +#[serde( + rename_all = "camelCase", + rename_all_fields = "camelCase", + tag = "status" +)] +pub enum CommitAttemptResult { + Committed { + result: OperationResult, + output_truncated: bool, + }, + HookRejected { + hook_name: String, + exit_status: Option, + output: Option, + output_truncated: bool, + bypass_supported: bool, + }, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GitHookFailure { + pub hook_name: String, + pub exit_status: Option, + pub output: Option, + pub output_truncated: bool, + pub bypass_supported: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde( + rename_all = "camelCase", + rename_all_fields = "camelCase", + tag = "status" +)] +pub enum GitHookAttemptResult { + Completed { + result: T, + hook_warning: Option, + output_truncated: bool, + }, + HookRejected { + hook_name: String, + exit_status: Option, + output: Option, + output_truncated: bool, + bypass_supported: bool, + }, +} + +#[cfg(test)] +mod commit_hook_serialisation_tests { + use super::{CommitAttemptResult, CommitProgressEvent}; + + #[test] + fn serialises_commit_hook_wire_fields_in_camel_case() { + let progress = serde_json::to_value(CommitProgressEvent::HookFinished { + hook_name: "pre-commit".to_string(), + exit_status: Some(1), + }) + .expect("serialise hook progress"); + assert_eq!( + progress, + serde_json::json!({ + "event": "hookFinished", + "hookName": "pre-commit", + "exitStatus": 1, + }) + ); + + let rejection = serde_json::to_value(CommitAttemptResult::HookRejected { + hook_name: "pre-commit".to_string(), + exit_status: Some(1), + output: Some("check failed".to_string()), + output_truncated: false, + bypass_supported: true, + }) + .expect("serialise hook rejection"); + assert_eq!( + rejection, + serde_json::json!({ + "status": "hookRejected", + "hookName": "pre-commit", + "exitStatus": 1, + "output": "check failed", + "outputTruncated": false, + "bypassSupported": true, + }) + ); + } } #[derive(Debug, Clone, Serialize)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 11926cf..51d83be 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -69,8 +69,8 @@ static BUNDLED_GIT_EXE: OnceLock> = OnceLock::new(); static CONFIGURED_GIT_EXE: OnceLock>> = OnceLock::new(); -#[cfg(windows)] -const MSIX_PACKAGE_FAMILY_NAME: &str = "cst8t.Gitmun_yqm0gq6me4wme"; +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) const MSIX_PACKAGE_FAMILY_NAME: &str = "cst8t.Gitmun_yqm0gq6me4wme"; #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn is_msix_build() -> bool { @@ -180,6 +180,12 @@ fn detect_git_backend() -> GitBackend { } pub(crate) fn git_command() -> std::process::Command { + git_command_with_environment(&[]) +} + +pub(crate) fn git_command_with_environment( + environment: &[(&str, &std::ffi::OsStr)], +) -> std::process::Command { let mut command = if let Some(git_exe) = configured_git_executable_path() { #[cfg(windows)] { @@ -196,7 +202,15 @@ pub(crate) fn git_command() -> std::process::Command { match git_backend() { GitBackend::FlatpakHost => { let mut cmd = std::process::Command::new("flatpak-spawn"); - cmd.args(["--host", "--env=LC_ALL=C", "--env=LANG=C", "git"]); + cmd.args(["--host", "--env=LC_ALL=C", "--env=LANG=C"]); + for (key, value) in environment { + let mut argument = std::ffi::OsString::from("--env="); + argument.push(key); + argument.push("="); + argument.push(value); + cmd.arg(argument); + } + cmd.arg("git"); cmd } GitBackend::FlatpakBundled => std::process::Command::new("/app/bin/git"), @@ -218,6 +232,10 @@ pub(crate) fn git_command() -> std::process::Command { command.env("LC_ALL", "C"); command.env("LANG", "C"); + command.env("GIT_TERMINAL_PROMPT", "0"); + for (key, value) in environment { + command.env(key, value); + } command } @@ -236,6 +254,25 @@ mod git_command_tests { assert!(command_env_is(&command, "LC_ALL", "C")); assert!(command_env_is(&command, "LANG", "C")); } + + #[test] + fn git_command_disables_terminal_prompt() { + let command = crate::git_command(); + assert!(command_env_is(&command, "GIT_TERMINAL_PROMPT", "0")); + } + + #[test] + fn git_command_applies_additional_environment() { + let command = crate::git_command_with_environment(&[( + "GIT_TRACE2_EVENT", + std::ffi::OsStr::new("/tmp/gitmun-trace"), + )]); + assert!(command_env_is( + &command, + "GIT_TRACE2_EVENT", + "/tmp/gitmun-trace" + )); + } } pub(crate) fn normalise_display_path(path: &str) -> String { @@ -1597,6 +1634,7 @@ pub fn run() { commands::settings::set_commit_message_recommended_length, commands::settings::set_auto_check_for_updates_on_launch, commands::settings::set_auto_install_updates, + commands::settings::set_auto_fetch_interval_minutes, commands::settings::set_update_endpoint, commands::settings::set_linux_graphics_mode, commands::settings::get_linux_terminal_options, @@ -1620,6 +1658,7 @@ pub fn run() { commands::history::conflict_accept_theirs, commands::history::conflict_accept_ours, commands::history::open_merge_tool, + commands::recent_repositories::sync_recent_repositories, commands::repo::get_commit_markers, commands::repo::get_commit_files, commands::repo::get_commit_details, diff --git a/src-tauri/tests/git.rs b/src-tauri/tests/git.rs index 26a8c0d..05ea52c 100644 --- a/src-tauri/tests/git.rs +++ b/src-tauri/tests/git.rs @@ -1,6 +1,7 @@ use std::fs; use std::path::Path; use std::process::Command; +use std::sync::{Arc, Mutex}; use tempfile::TempDir; use gitmun_lib::git::cli::CliGitHandler; @@ -8,12 +9,13 @@ use gitmun_lib::git::error_interpretation::GitErrorCategory; use gitmun_lib::git::gix_handler::GixGitHandler; use gitmun_lib::git::handler::GitOperationHandler; use gitmun_lib::git::types::{ - CommitDetailsRequest, CommitHistoryRequest, CommitLogScope, CommitRefKind, CommitRequest, - CreateBranchRequest, DeleteBranchRequest, ExportCommitPatchRequest, ExportPatchFileSelection, - ExportPatchRequest, ExportPatchScope, FileRequest, IdentityRequest, IdentityScope, - ImportPatchRequest, PushFailureKind, PushRequest, RepoRequest, RepoStatus, ResetMode, - ResetRequest, SetBranchUpstreamRequest, SetIdentityRequest, SshAllowedSignerReason, - StageFilesRequest, SubmoduleActionRequest, SubmoduleState, UnversionedItemKind, + BranchRequest, CommitAttemptResult, CommitDetailsRequest, CommitHistoryRequest, CommitLogScope, + CommitProgressEvent, CommitRefKind, CommitRequest, CreateBranchRequest, DeleteBranchRequest, + ExportCommitPatchRequest, ExportPatchFileSelection, ExportPatchRequest, ExportPatchScope, + FileRequest, GitHookAttemptResult, IdentityRequest, IdentityScope, ImportPatchRequest, + PushFailureKind, PushRequest, RepoRequest, RepoStatus, ResetMode, ResetRequest, + SetBranchUpstreamRequest, SetIdentityRequest, SshAllowedSignerReason, StageFilesRequest, + SubmoduleActionRequest, SubmoduleState, UnversionedItemKind, }; fn init_repo() -> TempDir { @@ -1436,6 +1438,7 @@ fn commit_creates_entry_in_log() { repo_path: dir.path().to_str().unwrap().to_string(), message: "add b.txt".to_string(), amend: None, + skip_hooks: false, }) .expect("commit_changes"); let commits = handler() @@ -1464,6 +1467,7 @@ fn commit_does_not_sign_when_gpgsign_is_unset() { repo_path: dir.path().to_str().unwrap().to_string(), message: "commit without signing".to_string(), amend: None, + skip_hooks: false, }) .expect("commit_changes should not sign when commit.gpgsign is unset"); @@ -1485,6 +1489,7 @@ fn commit_preserves_description_and_trailer_like_lines() { repo_path: dir.path().to_str().unwrap().to_string(), message: message.to_string(), amend: None, + skip_hooks: false, }) .expect("commit_changes"); @@ -1492,6 +1497,301 @@ fn commit_preserves_description_and_trailer_like_lines() { assert_eq!(committed_message, message); } +#[cfg(unix)] +#[test] +fn commit_progress_reports_hook_rejection_and_bypasses_supported_hooks() { + use std::os::unix::fs::PermissionsExt; + + let dir = init_repo(); + write_file(dir.path(), "checked.txt", "data"); + git(dir.path(), &["add", "checked.txt"]); + let hook_path = dir.path().join(".git/hooks/pre-commit"); + fs::write( + &hook_path, + "#!/bin/sh\necho hook output\necho hook error >&2\nexit 1\n", + ) + .expect("write hook"); + let mut permissions = fs::metadata(&hook_path) + .expect("hook metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook_path, permissions).expect("make hook executable"); + + let events = Arc::new(Mutex::new(Vec::new())); + let recorded_events = Arc::clone(&events); + let request = CommitRequest { + repo_path: dir.path().to_str().unwrap().to_string(), + message: "checked commit".to_string(), + amend: None, + skip_hooks: false, + }; + let result = handler() + .commit_changes_with_progress( + &request, + Arc::new(move |event| { + recorded_events.lock().expect("event lock").push(event); + }), + ) + .expect("hook rejection result"); + assert!(matches!( + result, + CommitAttemptResult::HookRejected { + ref hook_name, + bypass_supported: true, + .. + } if hook_name == "pre-commit" + )); + assert!( + events + .lock() + .expect("event lock") + .iter() + .any(|event| matches!( + event, + CommitProgressEvent::Output { text, .. } if text.contains("hook output") + )) + ); + assert!( + events + .lock() + .expect("event lock") + .iter() + .any(|event| matches!( + event, + CommitProgressEvent::HookStarted { hook_name } if hook_name == "pre-commit" + )) + ); + assert!( + events + .lock() + .expect("event lock") + .iter() + .any(|event| matches!( + event, + CommitProgressEvent::HookFinished { hook_name, exit_status: Some(1) } + if hook_name == "pre-commit" + )) + ); + + let bypass_result = handler() + .commit_changes_with_progress( + &CommitRequest { + skip_hooks: true, + ..request + }, + Arc::new(|_| {}), + ) + .expect("bypass result"); + assert!(matches!( + bypass_result, + CommitAttemptResult::Committed { .. } + )); +} + +#[cfg(unix)] +#[test] +fn commit_progress_classifies_commit_message_and_custom_hook_paths() { + use std::os::unix::fs::PermissionsExt; + + let run_hook = |hook_name: &str, custom_hooks_path: bool| { + let dir = init_repo(); + write_file(dir.path(), "checked.txt", "data"); + git(dir.path(), &["add", "checked.txt"]); + let hooks_dir = if custom_hooks_path { + let path = dir.path().join("custom-hooks"); + fs::create_dir(&path).expect("create custom hooks"); + git( + dir.path(), + &["config", "core.hooksPath", path.to_str().unwrap()], + ); + path + } else { + dir.path().join(".git/hooks") + }; + let hook_path = hooks_dir.join(hook_name); + fs::write(&hook_path, "#!/bin/sh\nexit 1\n").expect("write hook"); + let mut permissions = fs::metadata(&hook_path) + .expect("hook metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook_path, permissions).expect("make hook executable"); + handler() + .commit_changes_with_progress( + &CommitRequest { + repo_path: dir.path().to_str().unwrap().to_string(), + message: "checked commit".to_string(), + amend: None, + skip_hooks: false, + }, + Arc::new(|_| {}), + ) + .expect("hook rejection result") + }; + + assert!(matches!( + run_hook("commit-msg", false), + CommitAttemptResult::HookRejected { ref hook_name, bypass_supported: true, .. } + if hook_name == "commit-msg" + )); + assert!(matches!( + run_hook("prepare-commit-msg", false), + CommitAttemptResult::HookRejected { ref hook_name, bypass_supported: false, .. } + if hook_name == "prepare-commit-msg" + )); + assert!(matches!( + run_hook("pre-commit", true), + CommitAttemptResult::HookRejected { ref hook_name, .. } if hook_name == "pre-commit" + )); +} + +#[cfg(unix)] +#[test] +fn commit_progress_truncates_successful_hook_output() { + use std::os::unix::fs::PermissionsExt; + + let dir = init_repo(); + write_file(dir.path(), "large-output.txt", "data"); + git(dir.path(), &["add", "large-output.txt"]); + let hook_path = dir.path().join(".git/hooks/pre-commit"); + fs::write( + &hook_path, + "#!/bin/sh\ndd if=/dev/zero bs=1048577 count=1 2>/dev/null | tr '\\000' x\n", + ) + .expect("write hook"); + let mut permissions = fs::metadata(&hook_path) + .expect("hook metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook_path, permissions).expect("make hook executable"); + + let events = Arc::new(Mutex::new(Vec::new())); + let recorded_events = Arc::clone(&events); + let result = handler() + .commit_changes_with_progress( + &CommitRequest { + repo_path: dir.path().to_str().unwrap().to_string(), + message: "large hook output".to_string(), + amend: None, + skip_hooks: false, + }, + Arc::new(move |event| { + recorded_events.lock().expect("event lock").push(event); + }), + ) + .expect("successful commit"); + + assert!(matches!( + result, + CommitAttemptResult::Committed { + output_truncated: true, + .. + } + )); + assert_eq!( + events + .lock() + .expect("event lock") + .iter() + .filter(|event| matches!( + event, + CommitProgressEvent::Output { + truncated: true, + .. + } + )) + .count(), + 1 + ); + assert_eq!( + git_stdout(dir.path(), &["log", "-1", "--format=%s"]), + "large hook output" + ); +} + +#[cfg(unix)] +#[test] +fn pre_push_rejection_can_be_retried_without_hooks() { + use std::os::unix::fs::PermissionsExt; + + let (remote, local) = init_remote_with_clone(); + write_file(local.path(), "push-check.txt", "checked"); + git(local.path(), &["add", "push-check.txt"]); + git(local.path(), &["commit", "-m", "push check"]); + let hook_path = local.path().join(".git/hooks/pre-push"); + fs::write(&hook_path, "#!/bin/sh\necho push hook output >&2\nexit 1\n") + .expect("write pre-push hook"); + let mut permissions = fs::metadata(&hook_path) + .expect("hook metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook_path, permissions).expect("make hook executable"); + + let request = push_request(&local); + let rejected = handler() + .push_changes_with_progress(&request, false, Arc::new(|_| {})) + .expect("push result"); + assert!( + matches!(rejected, GitHookAttemptResult::HookRejected { ref hook_name, bypass_supported: true, .. } if hook_name == "pre-push") + ); + assert_ne!(head_hash(remote.path()), head_hash(local.path())); + + let bypassed = handler() + .push_changes_with_progress(&request, true, Arc::new(|_| {})) + .expect("bypassed push"); + assert!( + matches!(bypassed, GitHookAttemptResult::Completed { ref result, .. } if result.success) + ); + assert_eq!(head_hash(remote.path()), head_hash(local.path())); +} + +#[cfg(unix)] +#[test] +fn post_checkout_failure_reports_warning_without_repeating_checkout() { + use std::os::unix::fs::PermissionsExt; + + let dir = init_repo(); + git(dir.path(), &["branch", "feature/hook-warning"]); + let hook_path = dir.path().join(".git/hooks/post-checkout"); + let count_path = dir.path().join("hook-count.txt"); + fs::write( + &hook_path, + format!( + "#!/bin/sh\necho run >> '{}'\necho checkout hook output >&2\nexit 1\n", + count_path.display() + ), + ) + .expect("write post-checkout hook"); + let mut permissions = fs::metadata(&hook_path) + .expect("hook metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook_path, permissions).expect("make hook executable"); + + let result = handler() + .switch_branch_with_progress( + &BranchRequest { + repo_path: dir.path().to_string_lossy().into_owned(), + branch_name: "feature/hook-warning".to_string(), + }, + Arc::new(|_| {}), + ) + .expect("checkout warning result"); + assert!( + matches!(result, GitHookAttemptResult::Completed { hook_warning: Some(ref warning), .. } if warning.hook_name == "post-checkout") + ); + assert_eq!( + git_stdout(dir.path(), &["branch", "--show-current"]), + "feature/hook-warning" + ); + assert_eq!( + fs::read_to_string(count_path) + .expect("read hook count") + .lines() + .count(), + 1 + ); +} + #[test] fn commit_message_recovery_reads_commit_editmsg() { let dir = init_repo(); diff --git a/src/api/commands.ts b/src/api/commands.ts index e94fade..0acbde7 100644 --- a/src/api/commands.ts +++ b/src/api/commands.ts @@ -8,6 +8,10 @@ import type { CommitMarkers, CommitPrimaryAction, CommitVerification, + CommitAttemptResult, + CommitProgressEvent, + GitHookAttemptResult, + GitHookProgressEvent, CommitRequest, CommitMessageRecovery, CreateBranchRequest, @@ -134,12 +138,12 @@ export function getBranches(repoPath: string): Promise { return invoke("get_branches", {request: {repoPath}}); } -export function switchBranch(repoPath: string, branchName: string): Promise { - return invoke("switch_branch", {request: {repoPath, branchName}}); +export function switchBranch(repoPath: string, branchName: string, onProgress: Channel): Promise> { + return invoke>("switch_branch", {request: {repoPath, branchName}, onProgress}); } -export function createBranch(request: CreateBranchRequest): Promise { - return invoke("create_branch", {request}); +export function createBranch(request: CreateBranchRequest, onProgress: Channel): Promise> { + return invoke>("create_branch", {request, onProgress}); } export function deleteBranch(request: DeleteBranchRequest): Promise { @@ -158,16 +162,16 @@ export function createTag(request: CreateTagRequest): Promise { return invoke("create_tag", {request}); } -export function pushTag(request: PushTagRequest): Promise { - return invoke("push_tag", {request}); +export function pushTag(request: PushTagRequest, onProgress: Channel, skipHooks = false): Promise> { + return invoke>("push_tag", {request, onProgress, skipHooks}); } -export function deleteRemoteTag(request: DeleteRemoteTagRequest): Promise { - return invoke("delete_remote_tag", {request}); +export function deleteRemoteTag(request: DeleteRemoteTagRequest, onProgress: Channel, skipHooks = false): Promise> { + return invoke>("delete_remote_tag", {request, onProgress, skipHooks}); } -export function deleteRemoteBranch(request: DeleteRemoteBranchRequest): Promise { - return invoke("delete_remote_branch", {request}); +export function deleteRemoteBranch(request: DeleteRemoteBranchRequest, onProgress: Channel, skipHooks = false): Promise> { + return invoke>("delete_remote_branch", {request, onProgress, skipHooks}); } export function getCommitHistory( @@ -285,8 +289,17 @@ export function submodulePull(request: SubmoduleActionRequest): Promise("submodule_pull", {request}); } -export function commitChanges(repoPath: string, message: string, amend?: boolean): Promise { - return invoke("commit_changes", {request: {repoPath, message, amend}}); +export function commitChanges( + repoPath: string, + message: string, + amend: boolean | undefined, + onProgress: Channel, + skipHooks = false, +): Promise { + return invoke("commit_changes", { + request: {repoPath, message, amend, skipHooks}, + onProgress, + }); } export function getCommitMessageRecovery(repoPath: string): Promise { @@ -309,8 +322,8 @@ export function pullWithStrategy(repoPath: string, strategy: PullStrategy): Prom return invoke("pull_with_strategy", {request: {repoPath, strategy}}); } -export function pushChanges(request: PushRequest): Promise { - return invoke("push_changes", {request}); +export function pushChanges(request: PushRequest, onProgress: Channel, skipHooks = false): Promise> { + return invoke>("push_changes", {request, onProgress, skipHooks}); } export function setBranchUpstream(request: SetBranchUpstreamRequest): Promise { @@ -712,6 +725,17 @@ export function getStartupAction(): Promise { return invoke("get_startup_action"); } +export type RecentRepositoriesSyncRequest = { + paths: string[]; + categoryLabel: string; + accessedPath: string | null; + linuxSeedPaths: string[]; +}; + +export function syncRecentRepositories(request: RecentRepositoriesSyncRequest): Promise { + return invoke("sync_recent_repositories", {request}); +} + export function openRepoInNewWindow(path: string): Promise { return invoke("open_repo_in_new_window", {path}); } diff --git a/src/components/App.css b/src/components/App.css index 97d7a29..f1a19b9 100644 --- a/src/components/App.css +++ b/src/components/App.css @@ -279,14 +279,9 @@ border-radius: var(--radius-lg); background: transparent; color: var(--text-primary); - padding: 8px 10px; - display: grid; - grid-template-columns: auto minmax(0, 1fr); - column-gap: 9px; - row-gap: 2px; + padding: 0; + display: flex; align-items: center; - text-align: left; - cursor: pointer; } .app__empty-recent-item:hover { @@ -294,11 +289,31 @@ background: var(--bg-hover); } -.app__empty-recent-item svg { - grid-row: 1 / 3; +.app__empty-recent-select { + min-width: 0; + flex: 1; + border: 0; + background: transparent; + color: inherit; + padding: 8px 10px; + display: flex; + align-items: center; + gap: 9px; + text-align: left; + cursor: pointer; +} + +.app__empty-recent-select svg { + flex: 0 0 auto; color: var(--text-muted); } +.app__empty-recent-text { + min-width: 0; + display: grid; + gap: 2px; +} + .app__empty-recent-name, .app__empty-recent-path { min-width: 0; @@ -315,3 +330,29 @@ color: var(--text-muted); font-size: var(--font-size-sm); } + +.app__empty-recent-remove { + width: 28px; + height: 28px; + flex: 0 0 28px; + margin-right: 4px; + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: var(--text-muted); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.app__empty-recent-remove:hover { + background: var(--bg-elevated); + color: var(--text-primary); +} + +.app__empty-recent-select:focus-visible, +.app__empty-recent-remove:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: -2px; +} diff --git a/src/components/App.tsx b/src/components/App.tsx index 50908a7..0aeac4a 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -3,6 +3,7 @@ import {useTranslation} from "react-i18next"; import {invoke} from "@tauri-apps/api/core"; import {listen} from "@tauri-apps/api/event"; import {ask, open} from "@tauri-apps/plugin-dialog"; +import {platform as operatingSystem} from "@tauri-apps/plugin-os"; import {Toast} from "./Toast"; import {ProjectView} from "./ProjectView"; import {UpdateDialog} from "./update/UpdateDialog"; @@ -15,6 +16,12 @@ import {appendResultLog, setResultLogRepoPath} from "../utils/resultLog"; import {buildMainWindowTitle, repoNameFromPath} from "../utils/repoDisplayName"; import {applyThemeMode} from "../utils/theme"; import {applyUiTextScale} from "../utils/uiTextScale"; +import { + addRecentRepository, + loadRecentRepositories, + removeRecentRepository, + saveRecentRepositories, +} from "../utils/recentRepositories"; import { DEFAULT_LEFT_PANEL_WIDTH, DEFAULT_RIGHT_PANEL_WIDTH, @@ -36,6 +43,7 @@ const BACKEND_MODE_KEY = "gitmun.backendMode"; const SHOW_RESULT_LOG_KEY = "gitmun.showResultLog"; const THEME_MODE_KEY = "gitmun.themeMode"; const LEFT_PANE_COLLAPSED_KEY = "gitmun.leftPaneCollapsed"; +const LINUX_RECENT_REPOSITORIES_MIGRATION_KEY = "gitmun.linuxRecentRepositoriesMigrated"; const DEFAULT_ERROR_TOAST_CLEAR_DELAY_MS = 8000; function savePanelRatios(totalWidth: number, left: number, right: number): void { @@ -72,17 +80,24 @@ export function App() { const [repoPath, setRepoPath] = useState(null); const [repoDisplayName, setRepoDisplayName] = useState<{repoPath: string; name: string | null} | null>(null); const [ready, setReady] = useState(false); - const [recentRepos, setRecentRepos] = useState(() => { - try { - return JSON.parse(localStorage.getItem("gitmun.recentRepos") ?? "[]"); - } catch { - return []; - } - }); + const [recentRepos, setRecentRepos] = useState(() => loadRecentRepositories(localStorage)); + const recentReposRef = useRef(recentRepos); + const recentSyncQueueRef = useRef>(Promise.resolve()); + const initialRecentSyncStartedRef = useRef(false); const [recentRepoDisplayNames, setRecentRepoDisplayNames] = useState>({}); const [identityOpen, setIdentityOpen] = useState(false); const [confirmRevert, setConfirmRevert] = useState(true); const [settingsRevision, setSettingsRevision] = useState(0); + const [lastFetchAttemptAtByRepo, setLastFetchAttemptAtByRepo] = useState( + () => new Map(), + ); + const recordFetchAttempt = useCallback((path: string) => { + setLastFetchAttemptAtByRepo(previous => { + const next = new Map(previous); + next.set(path, Date.now()); + return next; + }); + }, []); const activeRepoDisplayName = repoPath && repoDisplayName?.repoPath === repoPath ? repoDisplayName.name : null; @@ -103,14 +118,75 @@ export function App() { right: parsePanelRatio(localStorage.getItem(RIGHT_PANEL_RATIO_KEY)), }); - const pushRecentRepo = useCallback((path: string) => { - setRecentRepos(prev => { - const next = [path, ...prev.filter(p => p !== path)].slice(0, 10); - localStorage.setItem("gitmun.recentRepos", JSON.stringify(next)); - return next; - }); + const storeRecentRepositories = useCallback((paths: readonly string[]) => { + const next = saveRecentRepositories(localStorage, paths); + recentReposRef.current = next; + setRecentRepos(next); + return next; }, []); + const synchroniseRecentRepositories = useCallback(( + accessedPath: string | null = null, + linuxSeedPaths: string[] = [], + ): Promise => { + const synchronisation = recentSyncQueueRef.current + .catch(() => undefined) + .then(async () => { + const removedPaths = await api.syncRecentRepositories({ + paths: recentReposRef.current, + categoryLabel: t("recentRepositories.category"), + accessedPath, + linuxSeedPaths, + }); + if (removedPaths.length === 0) return; + + const removedPathSet = new Set(removedPaths); + const next = recentReposRef.current.filter(path => !removedPathSet.has(path)); + if (next.length === recentReposRef.current.length) return; + storeRecentRepositories(next); + }); + recentSyncQueueRef.current = synchronisation.catch(() => undefined); + return synchronisation; + }, [storeRecentRepositories, t]); + + const persistRecentRepositories = useCallback((paths: readonly string[], accessedPath: string | null = null) => { + storeRecentRepositories(paths); + void synchroniseRecentRepositories(accessedPath); + }, [storeRecentRepositories, synchroniseRecentRepositories]); + + const pushRecentRepo = useCallback((path: string) => { + persistRecentRepositories(addRecentRepository(recentReposRef.current, path), path); + }, [persistRecentRepositories]); + + const removeRecentRepo = useCallback((path: string) => { + persistRecentRepositories(removeRecentRepository(recentReposRef.current, path)); + }, [persistRecentRepositories]); + + useEffect(() => { + if (initialRecentSyncStartedRef.current) return; + initialRecentSyncStartedRef.current = true; + storeRecentRepositories(recentReposRef.current); + + const isLinux = (() => { + try { + return operatingSystem() === "linux"; + } catch { + return false; + } + })(); + const shouldSeedLinuxHistory = isLinux + && localStorage.getItem(LINUX_RECENT_REPOSITORIES_MIGRATION_KEY) !== "true"; + const linuxSeedPaths = shouldSeedLinuxHistory + ? [...recentReposRef.current].reverse() + : []; + + synchroniseRecentRepositories(null, linuxSeedPaths).then(() => { + if (shouldSeedLinuxHistory) { + localStorage.setItem(LINUX_RECENT_REPOSITORIES_MIGRATION_KEY, "true"); + } + }).catch(() => undefined); + }, [synchroniseRecentRepositories]); + useEffect(() => { const paths = recentRepos.slice(0, 5); if (paths.length === 0) { @@ -681,6 +757,8 @@ export function App() { repoPath={repoPath} repoDisplayName={activeRepoDisplayName} settingsRevision={settingsRevision} + lastFetchAttemptAt={repoPath ? lastFetchAttemptAtByRepo.get(repoPath) ?? null : null} + onFetchAttemptComplete={recordFetchAttempt} platform={platform} showToast={showToast} recentRepos={recentRepos} @@ -688,6 +766,7 @@ export function App() { identityOpen={identityOpen} onIdentityToggle={() => setIdentityOpen(v => !v)} onRepoSelect={handleRepoSelect} + onRemoveRecentRepo={removeRecentRepo} onOpenRepoLocation={handleOpenRepoLocation} onOpenExistingClick={handleOpenExistingClick} onCloneClick={handleCloneClick} diff --git a/src/components/ProjectView.test.ts b/src/components/ProjectView.test.ts index e615044..aa6ed86 100644 --- a/src/components/ProjectView.test.ts +++ b/src/components/ProjectView.test.ts @@ -1,9 +1,13 @@ +// @vitest-environment jsdom +import React, {useState} from "react"; +import {fireEvent, render, screen} from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import i18n from "../i18n"; import type { BranchInfo, OperationResult } from "../types"; import { buildPushRequestForCurrentBranch, buildStashDropPrompt, + EmptyRecentRepositories, getEffectiveCommitAction, importPatchWithRecovery, isPatchConflictResult, @@ -13,6 +17,34 @@ import {localiseAiError} from "../features/ai"; const t = i18n.getFixedT("en", "projectView"); +describe("EmptyRecentRepositories", () => { + it("removes a repository without opening it and reveals the next stored entry", () => { + const onRepoSelect = vi.fn(); + const paths = Array.from({length: 6}, (_, index) => `/repos/repository-${index + 1}`); + + function RecentRepositoryHarness() { + const [recentPaths, setRecentPaths] = useState(paths); + return React.createElement(EmptyRecentRepositories, { + paths: recentPaths.slice(0, 5), + displayNames: {}, + onRepoSelect, + onRemoveRecentRepo: path => setRecentPaths(current => current.filter(item => item !== path)), + }); + } + + render(React.createElement(RecentRepositoryHarness)); + const removeButton = screen.getByRole("button", { + name: "Remove repository-1 from recent repositories", + }); + expect(removeButton).toHaveAttribute("title", "Remove repository-1 from recent repositories"); + fireEvent.click(removeButton); + + expect(onRepoSelect).not.toHaveBeenCalled(); + expect(screen.queryByText("repository-1")).not.toBeInTheDocument(); + expect(screen.getByText("repository-6")).toBeInTheDocument(); + }); +}); + describe("buildStashDropPrompt", () => { it("includes the stash index and message without brace syntax", () => { expect(buildStashDropPrompt({ index: 3, message: "WIP on main" }, t)) diff --git a/src/components/ProjectView.tsx b/src/components/ProjectView.tsx index 2164a24..13c007d 100644 --- a/src/components/ProjectView.tsx +++ b/src/components/ProjectView.tsx @@ -7,8 +7,9 @@ * in-flight async result from a previous project can ever survive into the * new one. */ -import React, { useState, useCallback, useEffect, useRef } from "react"; +import React, { useState, useCallback, useEffect, useRef, useDeferredValue, useMemo } from "react"; import { ask, open, save } from "@tauri-apps/plugin-dialog"; +import { Channel } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; @@ -30,7 +31,7 @@ import { CreateBranchDialog } from "./sidebar/CreateBranchDialog"; import { RenameBranchDialog } from "./sidebar/RenameBranchDialog"; import { StashPushDialog } from "./sidebar/StashPushDialog"; import { UpstreamDialog } from "./sidebar/UpstreamDialog"; -import { ChevLeftIcon, ChevRightIcon, FolderIcon, GitIcon } from "./icons"; +import { ChevLeftIcon, ChevRightIcon, CloseIcon, FolderIcon, GitIcon } from "./icons"; import { useGitStatus } from "../hooks/useGitStatus"; import { useGitBranches } from "../hooks/useGitBranches"; import { useGitLog } from "../hooks/useGitLog"; @@ -43,6 +44,7 @@ import { useGitStashes } from "../hooks/useGitStashes"; import { useStagingOperations } from "../hooks/useStagingOperations"; import { useProjectKeyboardShortcuts } from "../hooks/useProjectKeyboardShortcuts"; import { useRemoteOperations } from "../hooks/useRemoteOperations"; +import { useAutoFetch } from "../hooks/useAutoFetch"; export { buildPushRequestForCurrentBranch } from "../hooks/useRemoteOperations"; import * as api from "../api/commands"; import type { ResetMode } from "../api/commands"; @@ -51,10 +53,15 @@ import type { CommitLogScope, CommitMarkers, CommitPrimaryAction, + CommitProgressEvent, CreateBranchRequest, ExportPatchFileSelection, ExportPatchScope, GitIdentity, + GitHookAttemptResult, + GitHookFailure, + GitHookProgressEvent, + GitHookProgressState, ImportPatchRequest, LongRunningOperation, LongRunningOperationKind, @@ -255,6 +262,8 @@ export type ProjectViewProps = { repoDisplayName: string | null; /** Increments each time settings are saved - triggers a full data refresh. */ settingsRevision: number; + lastFetchAttemptAt: number | null; + onFetchAttemptComplete: (repoPath: string) => void; platform: PlatformType; showToast: (message: string, type?: ToastType) => void; recentRepos: string[]; @@ -262,6 +271,7 @@ export type ProjectViewProps = { identityOpen: boolean; onIdentityToggle: () => void; onRepoSelect: (path: string) => void; + onRemoveRecentRepo: (path: string) => void; onOpenRepoLocation: (kind: RepoOpenLocationKind) => void; onOpenExistingClick: () => void; onCloneClick: () => void; @@ -282,10 +292,67 @@ export type ProjectViewProps = { winRadius: number; }; +export function EmptyRecentRepositories({ + paths, + displayNames, + onRepoSelect, + onRemoveRecentRepo, +}: { + paths: string[]; + displayNames: Record; + onRepoSelect: (path: string) => void; + onRemoveRecentRepo: (path: string) => void; +}) { + const {t} = useTranslation("projectView"); + if (paths.length === 0) return null; + + return ( +
+
+
{t("emptyState.recentRepositories")}
+
+ {paths.map(path => { + const name = displayNameForRepoPath(path, displayNames[path]); + return ( +
+ + +
+ ); + })} +
+
+ ); +} + export function ProjectView({ repoPath, repoDisplayName, settingsRevision, + lastFetchAttemptAt, + onFetchAttemptComplete, platform, showToast, recentRepos, @@ -293,6 +360,7 @@ export function ProjectView({ identityOpen, onIdentityToggle, onRepoSelect, + onRemoveRecentRepo, onOpenRepoLocation, onOpenExistingClick, onCloneClick, @@ -325,6 +393,7 @@ export function ProjectView({ const { remotes, refresh: refreshRemotes } = useGitRemotes(repoPath); const { stashes, refresh: refreshStashes } = useGitStashes(repoPath); const [logScope, setLogScope] = useState("currentCheckout"); + const [autoFetchIntervalMinutes, setAutoFetchIntervalMinutes] = useState(0); const [selectedFile, setSelectedFile] = useState(null); const [selectedFileStaged, setSelectedFileStaged] = useState(false); @@ -358,6 +427,14 @@ export function ProjectView({ const [operationLock, setOperationLock] = useState(null); const operationLockRef = useRef(null); const nextOperationIdRef = useRef(1); + const [hookProgress, setHookProgress] = useState(null); + const [hookRejection, setHookRejection] = useState<(GitHookFailure & {operation: "commit" | "push"}) | null>(null); + const hookDecisionRef = useRef<((skipHooks: boolean) => void) | null>(null); + + useEffect(() => () => { + hookDecisionRef.current?.(false); + hookDecisionRef.current = null; + }, []); const [isRebaseActionRunning, setIsRebaseActionRunning] = useState(false); const [isCherryPickActionRunning, setIsCherryPickActionRunning] = useState(false); const [isRevertActionRunning, setIsRevertActionRunning] = useState(false); @@ -374,6 +451,7 @@ export function ProjectView({ const [showCommitGraph, setShowCommitGraph] = useState(readShowCommitGraphPreference); const [showAiWriting, setShowAiWriting] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const deferredSearchQuery = useDeferredValue(searchQuery); const [windowFocused, setWindowFocused] = useState(() => ( typeof document === "undefined" ? true : document.hasFocus() )); @@ -432,6 +510,16 @@ export function ProjectView({ pageSize: logPageSize, refresh: refreshLog, } = useGitLog(repoPath, logScope, windowFocused, showCommitGraphButton && showCommitGraph); + const searching = deferredSearchQuery.length > 0; + const visibleCommits = useMemo(() => { + if (!searching) return commits; + const q = deferredSearchQuery.toLowerCase(); + return commits.filter(c => + c.message.toLowerCase().includes(q) + || c.author.toLowerCase().includes(q) + || c.shortHash.toLowerCase().includes(q), + ); + }, [commits, deferredSearchQuery, searching]); const stagedFiles = status?.stagedFiles ?? []; const unstagedFiles = status?.changedFiles ?? []; const unversionedFiles = status?.unversionedFiles ?? []; @@ -643,6 +731,81 @@ export function ProjectView({ await Promise.all([refreshStatus(), refreshBranches(), refreshTags(), refreshRemotes(), refreshLog(), refreshStashes()]); }, [refreshStatus, refreshBranches, refreshTags, refreshRemotes, refreshLog, refreshStashes]); + const createHookProgressChannel = useCallback((operation: "commit" | "push" | "checkout") => { + const progress = new Channel(); + setHookProgress({operation, startedAt: Date.now(), phase: "running", hookName: null, output: "", outputTruncated: false, expanded: false}); + progress.onmessage = event => { + setHookProgress(current => { + if (!current || current.operation !== operation) return current; + if (event.event === "output") { + return {...current, output: `${current.output}${event.text}`.slice(-1024 * 1024), outputTruncated: current.outputTruncated || event.truncated}; + } + if (event.event === "hookStarted") return {...current, hookName: event.hookName}; + return current.hookName === event.hookName ? {...current, hookName: null} : current; + }); + }; + return progress; + }, []); + + const runPushHookOperation = useCallback(async ( + operation: (progress: Channel, skipHooks: boolean) => Promise>, + ): Promise => { + let skipHooks = false; + for (;;) { + let attempt: GitHookAttemptResult; + try { + attempt = await operation(createHookProgressChannel("push"), skipHooks); + } catch (error) { + setHookProgress(null); + throw error; + } + if (attempt.status === "completed") { + setHookProgress(null); + if (skipHooks) appendResultLog("info", t("log.pushHooksSkipped"), "git-cli"); + return attempt.result; + } + setHookProgress(current => current ? {...current, phase: "awaitingDecision", hookName: attempt.hookName, output: attempt.output ?? current.output, outputTruncated: attempt.outputTruncated, expanded: true} : current); + setHookRejection({...attempt, operation: "push"}); + skipHooks = await new Promise(resolve => { hookDecisionRef.current = resolve; }); + if (!skipHooks) { + appendResultLog("error", t("log.pushHookRejected", {hook: attempt.hookName}), "git-cli", undefined, attempt.output ?? undefined); + setHookProgress(null); + return null; + } + } + }, [createHookProgressChannel, t]); + + const runCheckoutHookOperation = useCallback(async ( + operation: (progress: Channel) => Promise>, + ) => { + let attempt: GitHookAttemptResult; + try { + attempt = await operation(createHookProgressChannel("checkout")); + } catch (error) { + setHookProgress(null); + throw error; + } + if (attempt.status === "hookRejected") { + throw new Error(attempt.output ?? t("toast.checkoutHookFailed")); + } + if (attempt.hookWarning) { + setHookProgress(current => ({ + operation: "checkout", + startedAt: current?.startedAt ?? Date.now(), + phase: "warning", + hookName: attempt.hookWarning?.hookName ?? null, + output: attempt.hookWarning?.output ?? current?.output ?? "", + outputTruncated: attempt.hookWarning?.outputTruncated ?? false, + expanded: true, + })); + showToast(t("toast.checkoutHookWarning"), "info"); + appendResultLog("error", t("log.checkoutHookWarning"), attempt.result.backendUsed, undefined, attempt.hookWarning.output ?? undefined); + } else { + setHookProgress(null); + } + return attempt.result; + }, [createHookProgressChannel, showToast, t]); + const handleForcePushComplete = useCallback(() => setRebasedBranchAwaitingPush(null), []); const { remoteOp, @@ -650,6 +813,7 @@ export function ProjectView({ pushRejectionAnalysis, upstreamDialogMode, fetch: handleFetch, + autoFetch: handleAutoFetch, fetchSingleRemote: handleFetchSingleRemote, pull: handlePull, push: handlePush, @@ -675,8 +839,28 @@ export function ProjectView({ refreshAll, showToast, onForcePushComplete: handleForcePushComplete, + onFetchAttemptComplete, + pushChanges: request => runPushHookOperation((progress, skipHooks) => api.pushChanges(request, progress, skipHooks)), }); + useEffect(() => { + let cancelled = false; + api.getSettings().then(settings => { + if (!cancelled) setAutoFetchIntervalMinutes(settings.autoFetchIntervalMinutes ?? 0); + }).catch(() => { + if (!cancelled) setAutoFetchIntervalMinutes(0); + }); + return () => { cancelled = true; }; + }, [settingsRevision]); + + useAutoFetch( + autoFetchIntervalMinutes, + Boolean(repoPath && windowFocused && !operationLock && !remoteOp), + repoPath, + lastFetchAttemptAt, + handleAutoFetch, + ); + const handleSaveLocalIdentity = useCallback(async (payload: Partial) => { await saveLocalIdentity(payload); await refreshAll(); @@ -846,6 +1030,19 @@ export function ProjectView({ } }, [commitPrimaryAction, showToast, t]); + const handleHookRejectionClose = useCallback(() => { + hookDecisionRef.current?.(false); + hookDecisionRef.current = null; + setHookRejection(null); + setHookProgress(null); + }, []); + + const handleHookRejectionBypass = useCallback(() => { + hookDecisionRef.current?.(true); + hookDecisionRef.current = null; + setHookRejection(null); + }, []); + const runCommitRequest = useCallback(async (message: string, amend: boolean) => { if (!repoPath) return false; if (rebaseInProgress) { @@ -861,15 +1058,68 @@ export function ProjectView({ return false; } try { - const result = await api.commitChanges(repoPath, message, amend); - showToast(amend ? t("toast.amendedCommit") : t("toast.commitCreated")); - appendResultLog("success", amend ? t("toast.amendedLatestCommit") : t("toast.createdCommit"), result.backendUsed); - await refreshAll(); - return true; + let skipHooks = false; + for (;;) { + let capturedOutput = ""; + const progress = new Channel(); + progress.onmessage = event => { + setHookProgress(current => { + if (!current || current.operation !== "commit") return current; + if (event.event === "output") { + capturedOutput = `${capturedOutput}${event.text}`.slice(-1024 * 1024); + return {...current, output: capturedOutput, outputTruncated: current.outputTruncated || event.truncated}; + } + if (event.event === "hookStarted") { + return {...current, hookName: event.hookName}; + } + return current.hookName === event.hookName ? {...current, hookName: null} : current; + }); + }; + setHookProgress({operation: "commit", startedAt: Date.now(), phase: "running", hookName: null, output: "", outputTruncated: false, expanded: false}); + const attempt = await api.commitChanges(repoPath, message, amend, progress, skipHooks); + if (attempt.status === "committed") { + const { result } = attempt; + showToast(amend ? t("toast.amendedCommit") : t("toast.commitCreated")); + appendResultLog( + "success", + amend ? t("toast.amendedLatestCommit") : t("toast.createdCommit"), + result.backendUsed, + undefined, + `${result.output ?? capturedOutput}${attempt.outputTruncated ? `\n${t("commitHooks.outputTruncated")}` : ""}` || undefined, + ); + if (skipHooks) { + appendResultLog("info", t("log.commitHooksSkipped"), result.backendUsed); + } + await refreshAll(); + return true; + } + + setHookProgress(current => current ? {...current, phase: "awaitingDecision", hookName: attempt.hookName, output: attempt.output ?? capturedOutput, outputTruncated: Boolean(attempt.outputTruncated), expanded: true} : current); + setHookRejection({...attempt, operation: "commit"}); + skipHooks = await new Promise(resolve => { + hookDecisionRef.current = resolve; + }); + if (!skipHooks) { + appendResultLog( + "error", + t("log.commitHookRejected", { + hook: attempt.hookName, + exitStatus: attempt.exitStatus ?? t("commitHooks.unknownExitStatus"), + }), + "git-cli", + undefined, + `${attempt.output ?? capturedOutput}${attempt.outputTruncated ? `\n${t("commitHooks.outputTruncated")}` : ""}` || undefined, + ); + return false; + } + } } catch (e) { showToast(String(e), "error"); appendResultLog("error", t("log.commitFailed", { message: String(e) }), "unknown"); return false; + } finally { + setHookProgress(null); + hookDecisionRef.current = null; } }, [repoPath, rebaseInProgress, cherryPickInProgress, revertInProgress, refreshAll, showToast, t]); @@ -1072,7 +1322,7 @@ export function ProjectView({ } try { - const result = await api.switchBranch(repoPath, branchName); + const result = await runCheckoutHookOperation(progress => api.switchBranch(repoPath, branchName, progress)); if (stashedRef) { showToast(t("toast.switchedWithStash", { message: result.message, stashRef: stashedRef }), "success"); } else { @@ -1095,12 +1345,12 @@ export function ProjectView({ } await refreshStatus(); } - }, [repoPath, cherryPickInProgress, rebaseInProgress, mergeInProgress, currentBranch, hasWorkingTreeChanges, refreshAll, refreshStatus, showToast, stashBeforeBranchSwitch, t]); + }, [repoPath, cherryPickInProgress, rebaseInProgress, mergeInProgress, currentBranch, hasWorkingTreeChanges, refreshAll, refreshStatus, runCheckoutHookOperation, showToast, stashBeforeBranchSwitch, t]); const handleCreateBranch = useCallback(async (request: CreateBranchRequest) => { if (!repoPath) return; try { - const result = await api.createBranch(request); + const result = await runCheckoutHookOperation(progress => api.createBranch(request, progress)); showToast(result.message, "success"); appendResultLog("success", result.message, result.backendUsed); await refreshAll(); @@ -1108,7 +1358,7 @@ export function ProjectView({ showToast(String(e), "error"); appendResultLog("error", t("log.createBranchFailed", { message: String(e) }), "unknown"); } - }, [repoPath, refreshAll, showToast, t]); + }, [repoPath, refreshAll, runCheckoutHookOperation, showToast, t]); const handleDeleteBranch = useCallback(async (branchName: string) => { if (!repoPath) return; @@ -1210,14 +1460,15 @@ export function ProjectView({ const remote = remotes[0]?.name; if (!remote) { showToast(t("toast.noRemotesConfigured"), "error"); return; } try { - const result = await api.pushTag({ repoPath, remote, tagName }); + const result = await runPushHookOperation((progress, skipHooks) => api.pushTag({ repoPath, remote, tagName }, progress, skipHooks)); + if (!result) return; showToast(result.message, "success"); appendResultLog("success", result.message, result.backendUsed); } catch (e) { showToast(String(e), "error"); appendResultLog("error", t("log.pushTagFailed", { message: String(e) }), "unknown"); } - }, [repoPath, remotes, showToast, t]); + }, [repoPath, remotes, runPushHookOperation, showToast, t]); const handleDeleteRemoteTag = useCallback(async (tagName: string) => { if (!repoPath) return; @@ -1228,7 +1479,8 @@ export function ProjectView({ }); if (!confirmed) return; try { - const result = await api.deleteRemoteTag({ repoPath, remote, tagName }); + const result = await runPushHookOperation((progress, skipHooks) => api.deleteRemoteTag({ repoPath, remote, tagName }, progress, skipHooks)); + if (!result) return; showToast(result.message, "success"); appendResultLog("success", result.message, result.backendUsed); await refreshAll(); @@ -1236,7 +1488,7 @@ export function ProjectView({ showToast(String(e), "error"); appendResultLog("error", t("log.deleteRemoteTagFailed", { message: String(e) }), "unknown"); } - }, [repoPath, remotes, refreshAll, showToast, t]); + }, [repoPath, remotes, refreshAll, runPushHookOperation, showToast, t]); const handleCreateBranchFromTag = useCallback((tagName: string) => { setCreateBranchFromTagName(tagName); @@ -1299,7 +1551,8 @@ export function ProjectView({ }); if (!confirmed) return; try { - const result = await api.deleteRemoteBranch({ repoPath, remote, branch }); + const result = await runPushHookOperation((progress, skipHooks) => api.deleteRemoteBranch({ repoPath, remote, branch }, progress, skipHooks)); + if (!result) return; showToast(result.message, "success"); appendResultLog("success", result.message, result.backendUsed); await refreshAll(); @@ -1307,7 +1560,7 @@ export function ProjectView({ showToast(String(e), "error"); appendResultLog("error", t("log.deleteRemoteBranchFailed", { message: String(e) }), "unknown"); } - }, [repoPath, refreshAll, showToast, t]); + }, [repoPath, refreshAll, runPushHookOperation, showToast, t]); const handleCheckoutRemoteBranch = useCallback(async (remoteBranchName: string) => { if (!repoPath) return; @@ -1342,14 +1595,14 @@ export function ProjectView({ } try { - const result = await api.createBranch({ + const result = await runCheckoutHookOperation(progress => api.createBranch({ repoPath, branchName: localBranchName, baseRef: remoteBranchName, checkoutAfterCreation: true, trackRemote: true, matchTrackingBranch: true, - }); + }, progress)); if (stashedRef) { showToast(t("toast.switchedWithStash", { message: result.message, stashRef: stashedRef }), "success"); } else { @@ -1368,7 +1621,7 @@ export function ProjectView({ } await refreshStatus(); } - }, [repoPath, cherryPickInProgress, rebaseInProgress, mergeInProgress, branches, handleSwitchBranch, refreshAll, refreshStatus, showToast, stashBeforeBranchSwitch, t]); + }, [repoPath, cherryPickInProgress, rebaseInProgress, mergeInProgress, branches, handleSwitchBranch, refreshAll, refreshStatus, runCheckoutHookOperation, showToast, stashBeforeBranchSwitch, t]); const handleAddRemote = useCallback(async (name: string, url: string) => { if (!repoPath) return; @@ -2163,6 +2416,7 @@ export function ProjectView({ onInitRepoClick={onInitRepoClick} onOpenExistingClick={onOpenExistingClick} onRepoSelect={onRepoSelect} + onRemoveRecentRepo={onRemoveRecentRepo} onOpenRepoLocation={onOpenRepoLocation} onFetch={handleFetch} onPull={handlePull} @@ -2279,21 +2533,15 @@ export function ProjectView({ cherryPickHead={cherryPickHead} revertInProgress={revertInProgress} revertHead={revertHead} - commits={searchQuery - ? commits.filter(c => { - const q = searchQuery.toLowerCase(); - return c.message.toLowerCase().includes(q) - || c.author.toLowerCase().includes(q) - || c.shortHash.toLowerCase().includes(q); - }) - : commits} - loadMore={searchQuery ? () => {} : loadMore} - hasMore={searchQuery ? false : hasMore} - loadingMore={searchQuery ? false : logLoadingMore} - loadMoreError={searchQuery ? null : logLoadMoreError} + commits={visibleCommits} + loadMore={searching ? () => {} : loadMore} + hasMore={searching ? false : hasMore} + loadingMore={searching ? false : logLoadingMore} + loadMoreError={searching ? null : logLoadMoreError} pageSize={logPageSize} logLoading={logLoading} logError={logError} + searching={searching} commitMarkers={commitMarkers} logScope={logScope} rowStriping={rowStriping} @@ -2361,6 +2609,10 @@ export function ProjectView({ onOpenMergeTool={handleOpenMergeTool} stagingOperation={stagingOperation} operationLock={operationLock} + hookProgress={hookProgress} + hookRejection={hookRejection} + onHookRejectionClose={handleHookRejectionClose} + onHookRejectionBypass={handleHookRejectionBypass} isCommitting={isCommitting} isRebaseActionRunning={isRebaseActionRunning} isCherryPickActionRunning={isCherryPickActionRunning} @@ -2429,30 +2681,12 @@ export function ProjectView({ {t("emptyState.openExisting")}
- {emptyStateRecentRepos.length > 0 && ( -
-
-
{t("emptyState.recentRepositories")}
-
- {emptyStateRecentRepos.map(path => { - const name = displayNameForRepoPath(path, recentRepoDisplayNames[path]); - return ( - - ); - })} -
-
- )} +
)} diff --git a/src/components/Titlebar.css b/src/components/Titlebar.css index eae2db8..6c7e98d 100644 --- a/src/components/Titlebar.css +++ b/src/components/Titlebar.css @@ -366,7 +366,7 @@ border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 4px; - min-width: 180px; + min-width: 220px; max-width: min(420px, calc(100vw - 16px)); box-sizing: border-box; z-index: 100; @@ -442,10 +442,49 @@ } .titlebar__open-menu-item--recent { + gap: 0; + padding: 0; +} + +.titlebar__open-menu-recent-select { + min-width: 0; + flex: 1; + border: 0; + background: transparent; + color: inherit; + padding: 6px 8px 6px 10px; font-family: var(--font-mono); + font-size: inherit; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + text-align: left; + cursor: pointer; +} + +.titlebar__open-menu-recent-remove { + width: 26px; + flex: 0 0 26px; + align-self: stretch; + border: 0; + border-radius: 0 var(--radius-md) var(--radius-md) 0; + background: transparent; + color: var(--text-muted); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.titlebar__open-menu-recent-remove:hover { + background: var(--bg-elevated); + color: var(--text-primary); +} + +.titlebar__open-menu-recent-select:focus-visible, +.titlebar__open-menu-recent-remove:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: -2px; } .titlebar__open-menu-sep { diff --git a/src/components/Titlebar.test.tsx b/src/components/Titlebar.test.tsx index 0dbcd45..695e56c 100644 --- a/src/components/Titlebar.test.tsx +++ b/src/components/Titlebar.test.tsx @@ -47,6 +47,9 @@ function renderTitlebar( aiConfigured?: boolean; onAiWriting?: () => void; onSettingsClick?: () => void; + recentRepos?: string[]; + onRepoSelect?: (path: string) => void; + onRemoveRecentRepo?: (path: string) => void; } = {}, ) { const onImportPatch = patchHandlers.onImportPatch ?? vi.fn(); @@ -61,7 +64,7 @@ function renderTitlebar( branches={branches} identityName="Gitmun Maintainer" identityAvatarUrl={null} - recentRepos={[]} + recentRepos={patchHandlers.recentRepos ?? []} searchQuery="" searchInputRef={{ current: null }} onSearchChange={vi.fn()} @@ -71,7 +74,8 @@ function renderTitlebar( onCloneClick={vi.fn()} onInitRepoClick={vi.fn()} onOpenExistingClick={vi.fn()} - onRepoSelect={vi.fn()} + onRepoSelect={patchHandlers.onRepoSelect ?? vi.fn()} + onRemoveRecentRepo={patchHandlers.onRemoveRecentRepo ?? vi.fn()} onOpenRepoLocation={onOpenRepoLocation} onFetch={vi.fn()} onPull={vi.fn()} @@ -126,6 +130,28 @@ describe("Titlebar", () => { expect(screen.getByText("Push")).toBeInTheDocument(); }); + it("removes a recent repository without opening it or closing the menu", () => { + const onRepoSelect = vi.fn(); + const onRemoveRecentRepo = vi.fn(); + renderTitlebar([makeBranch()], "Push", "/current", vi.fn(), { + recentRepos: ["/repos/one", "/repos/two"], + onRepoSelect, + onRemoveRecentRepo, + }); + fireEvent.click(screen.getByTitle("Open a repository")); + + const removeButton = screen.getByRole("button", { + name: "Remove one from recent repositories", + }); + expect(removeButton).toHaveAttribute("title", "Remove one from recent repositories"); + fireEvent.click(removeButton); + + expect(onRemoveRecentRepo).toHaveBeenCalledWith("/repos/one"); + expect(onRepoSelect).not.toHaveBeenCalled(); + expect(screen.getByRole("button", {name: "Remove two from recent repositories"})) + .toBeInTheDocument(); + }); + it("shows a disclosure with the full branch name when the branch label is truncated", () => { const longBranch = "feature/this-is-a-very-long-branch-name-that-should-not-crowd-toolbar-actions"; renderTitlebar([makeBranch({ name: longBranch })], "Push", "/repo", vi.fn(), { currentBranch: longBranch }); diff --git a/src/components/Titlebar.tsx b/src/components/Titlebar.tsx index cedd697..527d1fb 100644 --- a/src/components/Titlebar.tsx +++ b/src/components/Titlebar.tsx @@ -4,6 +4,7 @@ import { GitIcon, BranchIcon, FetchIcon, PullIcon, PushIcon, StashIcon, SearchIcon, SettingsIcon, FolderIcon, CopyIcon, ChevDownIcon, InfoIcon, TerminalIcon, OpenExternalIcon, MoreIcon, + CloseIcon, } from "./icons"; import * as api from "../api/commands"; import type { ResetMode } from "../api/commands"; @@ -37,6 +38,7 @@ type TitlebarProps = { onInitRepoClick: () => void; onOpenExistingClick: () => void; onRepoSelect: (path: string) => void; + onRemoveRecentRepo: (path: string) => void; onOpenRepoLocation: (kind: RepoOpenLocationKind) => void; onFetch: () => void; onPull: () => void; @@ -58,7 +60,7 @@ export function Titlebar({ repoDisplayName, identityName, identityAvatarUrl, recentRepos, searchQuery, searchInputRef, onSearchChange, onAboutClick, onSettingsClick, onIdentityClick, onCloneClick, onInitRepoClick, onOpenExistingClick, - onRepoSelect, onOpenRepoLocation, onFetch, onPull, onPush, pushLabel, pushDisabled = false, pushTitle, onStash, + onRepoSelect, onRemoveRecentRepo, onOpenRepoLocation, onFetch, onPull, onPush, pushLabel, pushDisabled = false, pushTitle, onStash, onReset, onImportPatch, onExportPatch, selectedPatchExportEnabled, identityOpen, remoteOp, aiEnabled = false, aiConfigured = false, onAiWriting, }: TitlebarProps) { @@ -197,14 +199,14 @@ export function Titlebar({ {/* Action buttons */}
- } label={t("actions.fetch")} onClick={onFetch} disabled={!repoPath} loading={remoteOp === "fetch"} /> - } label={t("actions.pull")} badge={behind > 0 ? String(behind) : undefined} onClick={onPull} disabled={!repoPath} loading={remoteOp === "pull"} /> + } label={t("actions.fetch")} onClick={onFetch} disabled={!repoPath || !!remoteOp} loading={remoteOp === "fetch"} /> + } label={t("actions.pull")} badge={behind > 0 ? String(behind) : undefined} onClick={onPull} disabled={!repoPath || !!remoteOp} loading={remoteOp === "pull"} /> } label={pushActionLabel} badge={pushActionLabel === t("actions.push") && ahead > 0 ? String(ahead) : undefined} onClick={onPush} - disabled={!repoPath || pushDisabled} + disabled={!repoPath || pushDisabled || !!remoteOp} loading={remoteOp === "push"} title={pushTitle} /> @@ -235,6 +237,7 @@ export function Titlebar({ recentRepos={recentRepos} onOpenExistingClick={onOpenExistingClick} onRepoSelect={onRepoSelect} + onRemoveRecentRepo={onRemoveRecentRepo} /> void; onRepoSelect: (path: string) => void; + onRemoveRecentRepo: (path: string) => void; }) { const { t } = useTranslation("titlebar"); const [open, setOpen] = useState(false); @@ -557,16 +561,36 @@ function OpenDropdown({ repoPath, recentRepos, onOpenExistingClick, onRepoSelect {recent.length > 0 && ( <>
- {recent.map(r => ( + {recent.map(path => { + const name = displayNameForRepoPath(path, null); + return (
{ setOpen(false); onRepoSelect(r); }} - title={r} > - {r.split("/").pop()} + +
- ))} + ); + })} )}
diff --git a/src/components/centre/CentrePanel.css b/src/components/centre/CentrePanel.css index 74712ba..0da9425 100644 --- a/src/components/centre/CentrePanel.css +++ b/src/components/centre/CentrePanel.css @@ -139,6 +139,56 @@ flex-shrink: 0; } +.staging__operation-failed { + display: grid; + width: 16px; + height: 16px; + place-items: center; + border-radius: 50%; + color: var(--text-on-accent); + background: var(--red); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + flex-shrink: 0; +} + +.staging__commit-output, +.commit-hook-dialog__output { + max-height: 180px; + margin: 6px 14px 0; + padding: 8px; + overflow: auto; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + color: var(--text-primary); + background: var(--bg-elevated); + font-family: var(--font-mono); + font-size: var(--font-size-xs); + white-space: pre-wrap; +} + +.commit-hook-dialog { + width: 560px; +} + +.commit-hook-dialog__summary, +.commit-hook-dialog__warning { + color: var(--text-secondary); + font-size: var(--font-size-sm); + line-height: 1.4; +} + +.commit-hook-dialog__warning { + padding: 8px 10px; + border-left: 3px solid var(--yellow); + background: var(--bg-elevated); +} + +.commit-hook-dialog__output { + max-height: min(40vh, 320px); + margin: 0; +} + .staging__operation-copy { display: flex; flex: 1; @@ -800,11 +850,16 @@ cursor: pointer; } -.log-view__toolbar-toggle:hover { +.log-view__toolbar-toggle:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-hover); } +.log-view__toolbar-toggle:disabled { + opacity: 0.45; + cursor: default; +} + .log-view__toolbar-toggle--active { color: var(--text-on-accent); background: var(--accent); diff --git a/src/components/centre/CentrePanel.test.tsx b/src/components/centre/CentrePanel.test.tsx index 184f866..1c9b17c 100644 --- a/src/components/centre/CentrePanel.test.tsx +++ b/src/components/centre/CentrePanel.test.tsx @@ -204,6 +204,39 @@ describe("CentrePanel commit graph toggle", () => { expect(container.querySelector(".log-view__graph")).toBeNull(); expect(localStorage.getItem("gitmun.showCommitGraph")).toBe("true"); }); + + it("disables the commit graph while searching without changing the saved preference", () => { + localStorage.setItem("gitmun.showCommitGraph", "true"); + const onCommitGraphVisibilityChange = vi.fn(); + + const { container } = renderCentrePanel({ + searching: true, + onCommitGraphVisibilityChange, + }); + + expect(container.querySelector(".log-view__graph")).toBeNull(); + expect(screen.getByLabelText("Hide commit graph")).toBeDisabled(); + expect(localStorage.getItem("gitmun.showCommitGraph")).toBe("true"); + expect(onCommitGraphVisibilityChange).toHaveBeenLastCalledWith(true); + }); + + it("restores the commit graph when search is cleared", () => { + localStorage.setItem("gitmun.showCommitGraph", "true"); + const onCommitGraphVisibilityChange = vi.fn(); + const { container, props, rerender } = renderCentrePanel({ + searching: true, + onCommitGraphVisibilityChange, + }); + + expect(container.querySelector(".log-view__graph")).toBeNull(); + + rerender(); + + expect(container.querySelector(".log-view__graph")).not.toBeNull(); + expect(screen.getByLabelText("Hide commit graph")).toBeEnabled(); + expect(localStorage.getItem("gitmun.showCommitGraph")).toBe("true"); + expect(onCommitGraphVisibilityChange).toHaveBeenLastCalledWith(true); + }); }); describe("CentrePanel operation feedback", () => { @@ -324,6 +357,103 @@ describe("CentrePanel operation feedback", () => { }); }); +describe("CentrePanel tab persistence", () => { + it("keeps both views mounted and hides the log when Changes is active", () => { + renderCentrePanel({ activeTab: "changes" }); + + expect(screen.getByTestId("staging-view")).toBeInTheDocument(); + const log = screen.getByTestId("log-view"); + expect(log).toBeInTheDocument(); + expect(log.style.display).toBe("none"); + }); + + it("keeps both views mounted and shows the log when Log is active", () => { + renderCentrePanel({ activeTab: "log" }); + + expect(screen.getByTestId("staging-view")).toBeInTheDocument(); + const log = screen.getByTestId("log-view"); + expect(log).toBeInTheDocument(); + expect(log.style.display).not.toBe("none"); + }); + + it("does not remount either view when switching tabs", () => { + const { props, rerender } = renderCentrePanel({ activeTab: "changes" }); + const staging = screen.getByTestId("staging-view"); + const log = screen.getByTestId("log-view"); + + rerender(); + + expect(screen.getByTestId("staging-view")).toBe(staging); + expect(screen.getByTestId("log-view")).toBe(log); + expect(screen.getByTestId("log-view").style.display).not.toBe("none"); + + rerender(); + + expect(screen.getByTestId("staging-view")).toBe(staging); + expect(screen.getByTestId("log-view")).toBe(log); + expect(screen.getByTestId("log-view").style.display).toBe("none"); + }); +}); + +describe("CentrePanel hook feedback", () => { + it("shows push hook progress while the Log tab is active", () => { + renderCentrePanel({ + activeTab: "log", + hookProgress: { + operation: "push", + startedAt: Date.now(), + phase: "running", + hookName: "pre-push", + output: "Checking refs\n", + outputTruncated: false, + expanded: false, + }, + }); + + expect(screen.getByRole("status")).toHaveTextContent("Running pre-push hook"); + fireEvent.click(screen.getByRole("button", {name: "View output"})); + expect(screen.getByText("Checking refs")).toBeInTheDocument(); + }); + + it("offers the operation-specific push bypass", () => { + const onBypass = vi.fn(); + renderCentrePanel({ + hookRejection: { + operation: "push", + hookName: "pre-push", + exitStatus: 1, + output: "Push rejected", + outputTruncated: false, + bypassSupported: true, + }, + onHookRejectionBypass: onBypass, + }); + + fireEvent.click(screen.getByRole("button", {name: "Push without hooks"})); + expect(onBypass).toHaveBeenCalledOnce(); + }); + + it("reports checkout completion with a dismissible warning", () => { + const onDismiss = vi.fn(); + renderCentrePanel({ + hookProgress: { + operation: "checkout", + startedAt: Date.now(), + phase: "warning", + hookName: "post-checkout", + output: "Environment setup failed", + outputTruncated: false, + expanded: true, + }, + onHookRejectionClose: onDismiss, + }); + + expect(screen.getByRole("status")).toHaveTextContent("Checkout completed with a warning"); + fireEvent.click(screen.getByRole("button", {name: "Dismiss"})); + expect(onDismiss).toHaveBeenCalledOnce(); + }); +}); + describe("CentrePanel AI conflict lock", () => { it("disables merge workflow actions while AI conflict resolution is active", () => { renderCentrePanel({ diff --git a/src/components/centre/CentrePanel.tsx b/src/components/centre/CentrePanel.tsx index 4796017..af48035 100644 --- a/src/components/centre/CentrePanel.tsx +++ b/src/components/centre/CentrePanel.tsx @@ -15,6 +15,8 @@ import type { CommitPrimaryAction, ConflictFileItem, FileStatusItem, + GitHookFailure, + GitHookProgressState, LongRunningOperation, OperationFeedbackContent, RowStriping, @@ -64,6 +66,7 @@ type CentrePanelProps = { pageSize: number; logLoading: boolean; logError: string | null; + searching?: boolean; commitMarkers: CommitMarkers; logScope: CommitLogScope; rowStriping: RowStriping; @@ -125,6 +128,10 @@ type CentrePanelProps = { onOpenMergeTool: (path: string) => void; stagingOperation: StagingOperation | null; operationLock: LongRunningOperation | null; + hookProgress?: GitHookProgressState | null; + hookRejection?: (GitHookFailure & {operation: "commit" | "push"}) | null; + onHookRejectionClose?: () => void; + onHookRejectionBypass?: () => void; isCommitting: boolean; isRebaseActionRunning: boolean; isCherryPickActionRunning: boolean; @@ -140,6 +147,51 @@ type CentrePanelProps = { onStopAiConflictBatchFailure?: () => void; }; +function HookProgressBanner({progress, onDismiss}: {progress: GitHookProgressState; onDismiss: () => void}) { + const {t} = useTranslation("centre"); + const [expanded, setExpanded] = React.useState(progress.expanded); + const [elapsedSeconds, setElapsedSeconds] = React.useState(0); + React.useEffect(() => setExpanded(progress.expanded), [progress.expanded]); + React.useEffect(() => { + const update = () => setElapsedSeconds(Math.floor((Date.now() - progress.startedAt) / 1000)); + update(); + const timer = window.setInterval(update, 1000); + return () => window.clearInterval(timer); + }, [progress.startedAt]); + const title = progress.phase === "warning" + ? t("gitHooks.checkoutWarningTitle") + : progress.phase === "awaitingDecision" + ? t("gitHooks.failedTitle", {operation: t(`gitHooks.operations.${progress.operation}`)}) + : progress.hookName + ? t("gitHooks.runningHook", {hook: progress.hookName}) + : t("gitHooks.runningOperation", {operation: t(`gitHooks.operations.${progress.operation}`)}); + return
+
+ {progress.phase === "running" ? + {expanded && progress.output &&
{progress.output}{progress.outputTruncated ? `\n${t("gitHooks.outputTruncated")}` : ""}
} +
; +} + +function HookFailureDialog({failure, onClose, onBypass}: {failure: GitHookFailure & {operation: "commit" | "push"}; onClose: () => void; onBypass: () => void}) { + const {t} = useTranslation("centre"); + const closeButtonRef = React.useRef(null); + React.useEffect(() => { closeButtonRef.current?.focus(); }, []); + return <>
+
{t("gitHooks.failedTitle", {operation: t(`gitHooks.operations.${failure.operation}`)})}
+
{t("gitHooks.failedDescription", {hook: failure.hookName, exitStatus: failure.exitStatus ?? t("gitHooks.unknownExitStatus")})}
+ {failure.bypassSupported &&
{t(`gitHooks.bypassWarning.${failure.operation}`)}
} + {failure.output &&
{failure.output}{failure.outputTruncated ? `\n${t("gitHooks.outputTruncated")}` : ""}
} +
{failure.bypassSupported && }
+
; +} + function useDelayedOperationFeedback(operation: LongRunningOperation | null) { const [now, setNow] = React.useState(() => Date.now()); @@ -221,20 +273,21 @@ function getOperationContent( export function CentrePanel(props: CentrePanelProps) { const { t } = useTranslation("centre"); const [showCommitGraph, setShowCommitGraph] = React.useState(readShowCommitGraphPreference); - const effectiveShowCommitGraph = props.showCommitGraphButton && showCommitGraph; + const preferredShowCommitGraph = props.showCommitGraphButton && showCommitGraph; + const effectiveShowCommitGraph = preferredShowCommitGraph && !props.searching; const tab = props.activeTab; const operationContent = getOperationContent(props.operationLock, t); const operationFeedback = useDelayedOperationFeedback(props.operationLock); const inlineOperationContent = operationFeedback.showInline ? operationContent : null; - const popupOperationContent = operationFeedback.showPopup && operationContent + const popupOperationContent = operationFeedback.showPopup && operationContent && !props.hookProgress ? { ...operationContent, message: t("operation.stillRunningMessage") } : null; const submoduleChanges = props.submodules.filter(submodule => submodule.state !== "clean").length; const totalChanges = props.stagedFiles.length + props.unstagedFiles.length + props.unversionedFiles.length + submoduleChanges; React.useEffect(() => { - props.onCommitGraphVisibilityChange?.(effectiveShowCommitGraph); - }, [effectiveShowCommitGraph, props.onCommitGraphVisibilityChange]); + props.onCommitGraphVisibilityChange?.(preferredShowCommitGraph); + }, [preferredShowCommitGraph, props.onCommitGraphVisibilityChange]); const handleToggleCommitGraph = () => { setShowCommitGraph(previous => { @@ -301,6 +354,7 @@ export function CentrePanel(props: CentrePanelProps) { interactionLocked={props.aiResolvingPath !== null} /> )} + {props.hookProgress && {})} />}
{/* - Both panels are always in the DOM. Mounting LogView on first click is - expensive (DOM creation + IntersectionObserver + avatar fetches). By - keeping both rendered and toggling CSS display, switching tabs is a - zero-cost CSS property change instead of a full React mount. + Both panels stay mounted so tab switches keep Log scroll, selection, + and graph state. Changes stays CSS-hidden so CommitBox drafts and + in-progress AI conflict UI keep their Effects. Log uses Activity so + its Effects pause while hidden, without dropping DOM or React state. */}
{})} />
-
+ -
+ + {props.hookRejection && {})} onBypass={props.onHookRejectionBypass ?? (() => {})} />} {popupOperationContent && ( <>
diff --git a/src/components/centre/LogView.test.tsx b/src/components/centre/LogView.test.tsx index 1bd5a38..3e3fade 100644 --- a/src/components/centre/LogView.test.tsx +++ b/src/components/centre/LogView.test.tsx @@ -819,6 +819,33 @@ describe("LogView commit selection", () => { expect(screen.queryByRole("button", { name: "View next 100 commits" })).not.toBeInTheDocument(); }); + it("shows no commits yet when the history is empty", () => { + renderLog({ commits: [] }); + + expect(screen.getByText("No commits yet")).toBeInTheDocument(); + }); + + it("shows no commits found when a search matches nothing", () => { + renderLog({ commits: [], searching: true }); + + expect(screen.getByText("No commits found")).toBeInTheDocument(); + expect(screen.queryByText("No commits yet")).not.toBeInTheDocument(); + }); + + it("shows no commits found for an empty all-refs search", () => { + renderLog({ commits: [], searching: true, logScope: "allRefs" }); + + expect(screen.getByText("No commits found")).toBeInTheDocument(); + expect(screen.queryByText("No commits were returned for any refs.")).not.toBeInTheDocument(); + }); + + it("keeps no commits found when a search refresh sets loading", () => { + renderLog({ commits: [], searching: true, logLoading: true }); + + expect(screen.getByText("No commits found")).toBeInTheDocument(); + expect(screen.queryByText("Loading commit history...")).not.toBeInTheDocument(); + }); + it("hides the load more footer while the first page is loading", () => { renderLog({ commits: [], hasMore: true, logLoading: true }); diff --git a/src/components/centre/LogView.tsx b/src/components/centre/LogView.tsx index 5069c14..810829e 100644 --- a/src/components/centre/LogView.tsx +++ b/src/components/centre/LogView.tsx @@ -18,6 +18,7 @@ import type { } from "../../types"; import { addSshSigningKeyToAllowedSigners, + getSettings, getSshAllowedSignerStatus, verifyCommits, } from "../../api/commands"; @@ -515,6 +516,7 @@ type LogViewProps = { pageSize: number; logLoading: boolean; logError: string | null; + searching?: boolean; commitMarkers: CommitMarkers; logScope: CommitLogScope; rowStriping: RowStriping; @@ -622,6 +624,7 @@ export function LogView({ pageSize, logLoading, logError, + searching = false, commitMarkers, logScope, rowStriping, @@ -665,6 +668,7 @@ export function LogView({ const verificationPumpQueuedRef = useRef(false); const verificationRequestIdRef = useRef(0); const lastSettingsRef = useRef(null); + const signatureSettingsEffectMountedRef = useRef(false); const visibleRangeRef = useRef({ startIndex: 0, endIndex: 19 }); const pendingRevealIndexRef = useRef(null); const commitHashes = useMemo(() => new Set(commits.map(c => c.hash)), [commits]); @@ -1096,6 +1100,16 @@ export function LogView({ let cancelled = false; let unlisten: (() => void) | null = null; (async () => { + try { + const next = await getSettings(); + if (!cancelled && signatureSettingsChanged(lastSettingsRef.current, next)) { + verifyVisibleSignedCommits(visibleRangeRef.current.startIndex, visibleRangeRef.current.endIndex, true); + } + if (!cancelled) lastSettingsRef.current = next; + } catch { + // Keep the last known snapshot if settings cannot be read. + } + if (cancelled) return; const fn = await listen("settings-updated", (event) => { if (signatureSettingsChanged(lastSettingsRef.current, event.payload)) { verifyVisibleSignedCommits(visibleRangeRef.current.startIndex, visibleRangeRef.current.endIndex, true); @@ -1117,6 +1131,10 @@ export function LogView({ useEffect(() => { let cancelled = false; let unlisten: (() => void) | null = null; + if (signatureSettingsEffectMountedRef.current) { + verifyVisibleSignedCommits(visibleRangeRef.current.startIndex, visibleRangeRef.current.endIndex, true); + } + signatureSettingsEffectMountedRef.current = true; (async () => { const fn = await listen("signature-settings-updated", () => { verifyVisibleSignedCommits(visibleRangeRef.current.startIndex, visibleRangeRef.current.endIndex, true); @@ -1328,6 +1346,9 @@ export function LogView({ if (logError) { return
{t("log.loadFailed", { message: logError })}
; } + if (searching) { + return
{t("log.noCommitsFound")}
; + } if (logLoading) { return
{t("log.loading")}
; } diff --git a/src/components/centre/StagingView.test.tsx b/src/components/centre/StagingView.test.tsx index 1d1e099..c138879 100644 --- a/src/components/centre/StagingView.test.tsx +++ b/src/components/centre/StagingView.test.tsx @@ -634,4 +634,67 @@ describe("StagingView file tree", () => { expect(screen.getByText("root-600.ts")).toBeInTheDocument(); }); + + it("shows live commit hook output when expanded", () => { + renderStagingView({ + commitProgress: { + startedAt: Date.now() - 2_000, + phase: "running", + hookName: "pre-commit", + output: "Checking formatting\n", + outputTruncated: false, + expanded: false, + }, + }); + + expect(screen.getByText("Running pre-commit hook")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", {name: "View output"})); + expect(screen.getByText("Checking formatting")).toBeInTheDocument(); + }); + + it("shows a hook rejection dialog with a supported bypass action", () => { + const onClose = vi.fn(); + const onBypass = vi.fn(); + renderStagingView({ + hookRejection: { + hookName: "commit-msg", + exitStatus: 1, + output: "Subject is required", + outputTruncated: false, + bypassSupported: true, + }, + onHookRejectionClose: onClose, + onHookRejectionBypass: onBypass, + }); + + expect(screen.getByRole("alertdialog")).toHaveTextContent("The commit-msg hook exited with status 1."); + fireEvent.click(screen.getByRole("button", {name: "Commit without hooks"})); + expect(onBypass).toHaveBeenCalledOnce(); + fireEvent.click(screen.getByRole("button", {name: "Close"})); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("does not offer bypass for a non-bypassable hook", () => { + renderStagingView({ + commitProgress: { + startedAt: Date.now() - 2_000, + phase: "awaitingDecision", + hookName: "prepare-commit-msg", + output: "Commit message preparation failed", + outputTruncated: false, + expanded: true, + }, + hookRejection: { + hookName: "prepare-commit-msg", + exitStatus: 1, + output: "Commit message preparation failed", + outputTruncated: false, + bypassSupported: false, + }, + }); + + expect(screen.getByText("Choose how to continue.")).toBeInTheDocument(); + expect(screen.getByRole("alertdialog")).toBeInTheDocument(); + expect(screen.queryByRole("button", {name: "Commit without hooks"})).not.toBeInTheDocument(); + }); }); diff --git a/src/components/centre/StagingView.tsx b/src/components/centre/StagingView.tsx index bf416bc..793a748 100644 --- a/src/components/centre/StagingView.tsx +++ b/src/components/centre/StagingView.tsx @@ -5,7 +5,9 @@ import { useTranslation } from "react-i18next"; import { FileRow } from "./FileRow"; import { CommitBox } from "./CommitBox"; import type { + CommitHookRejection, CommitPrimaryAction, + CommitProgressState, ConflictFileItem, FileStatusItem, OperationFeedbackContent, @@ -15,7 +17,7 @@ import type { UnversionedItem, } from "../../types"; import { getNumstat, openSettingsWindow } from "../../api/commands"; -import { buildFileTree, descendantFilePaths, type FileTreeDirectoryNode, type FileTreeNode } from "../../utils/fileTree"; +import { buildFileTree, descendantFilePaths, type FileTreeDirectoryNode, type VisibleFileTreeRow, visibleFileTreeRows } from "../../utils/fileTree"; import { ChevDownIcon, ChevRightIcon, FolderIcon } from "../icons"; type StagingViewProps = { @@ -69,6 +71,10 @@ type StagingViewProps = { onOpenMergeTool: (path: string) => void; stagingOperation: StagingOperation | null; inlineOperation: OperationFeedbackContent | null; + commitProgress?: CommitProgressState | null; + hookRejection?: CommitHookRejection | null; + onHookRejectionClose?: () => void; + onHookRejectionBypass?: () => void; isCommitting: boolean; lastCommitMessage: string; rowStriping: RowStriping; @@ -91,22 +97,16 @@ type CachedNumstat = { type TreeSection = "staged" | "unstaged"; -type VisibleTreeRow = - | { type: "directory"; node: FileTreeDirectoryNode; depth: number; expanded: boolean } - | { type: "file"; node: Extract; depth: number; fileIndex: number }; - type StagingListRow = | { type: "section"; key: string; section: "submodules" | "conflicts" | TreeSection } | { type: "submodule"; key: string; submodule: SubmoduleStatus; index: number } | { type: "conflict"; key: string; file: ConflictFileItem; index: number } - | { type: "tree"; key: string; section: TreeSection; row: VisibleTreeRow } + | { type: "tree"; key: string; section: TreeSection; row: VisibleFileTreeRow } | { type: "empty"; key: string; section: TreeSection }; const NUMSTAT_REFRESH_MS = 7000; const NUMSTAT_BATCH_SIZE = 6; const NUMSTAT_FAILURE_BACKOFF_MS = 3000; -const AUTO_COLLAPSE_SECTION_THRESHOLD = 500; -const AUTO_COLLAPSE_DIRECTORY_THRESHOLD = 100; const SUBMODULE_STATE_LABELS: Record = { clean: "Clean", @@ -126,46 +126,6 @@ function folderStateKey(section: TreeSection, path: string): string { return `${section}:${path}`; } -function defaultDirectoryExpanded(node: FileTreeDirectoryNode, depth: number, totalFiles: number): boolean { - if (totalFiles <= AUTO_COLLAPSE_SECTION_THRESHOLD) return true; - return depth > 0 && node.fileCount < AUTO_COLLAPSE_DIRECTORY_THRESHOLD; -} - -function isDirectoryExpanded( - section: TreeSection, - node: FileTreeDirectoryNode, - depth: number, - totalFiles: number, - expandedFolders: Record, -): boolean { - const key = folderStateKey(section, node.path); - return expandedFolders[key] ?? defaultDirectoryExpanded(node, depth, totalFiles); -} - -function visibleTreeRows( - nodes: FileTreeNode[], - section: TreeSection, - expandedFolders: Record, - totalFiles: number, -): VisibleTreeRow[] { - let fileIndex = 0; - - const visit = (currentNodes: FileTreeNode[], depth: number): VisibleTreeRow[] => - currentNodes.flatMap((node): VisibleTreeRow[] => { - if (node.type === "file") { - const row = { type: "file" as const, node, depth, fileIndex }; - fileIndex += 1; - return [row]; - } - - const expanded = node.children.length > 0 && isDirectoryExpanded(section, node, depth, totalFiles, expandedFolders); - const children = expanded ? visit(node.children, depth + 1) : []; - return [{ type: "directory", node, depth, expanded }, ...children]; - }); - - return visit(nodes, 0); -} - function shortHash(hash: string | null): string { return hash ? hash.slice(0, 7) : "-"; } @@ -187,6 +147,96 @@ function OperationInlineFeedback({ ); } +function CommitHookProgress({progress}: {progress: NonNullable}) { + const { t } = useTranslation("centre"); + const [expanded, setExpanded] = useState(progress.expanded); + const [elapsedSeconds, setElapsedSeconds] = useState(0); + + useEffect(() => { + setExpanded(progress.expanded); + }, [progress.expanded]); + + useEffect(() => { + const update = () => setElapsedSeconds(Math.floor((Date.now() - progress.startedAt) / 1000)); + update(); + const timer = window.setInterval(update, 1000); + return () => window.clearInterval(timer); + }, [progress.startedAt]); + + const title = progress.phase === "awaitingDecision" + ? t("commitHooks.awaitingDecision") + : progress.hookName + ? t("commitHooks.runningHook", {hook: progress.hookName}) + : t("commitHooks.committing"); + + return ( +
+
+ {progress.phase === "running" ? + {expanded && progress.output && ( +
{progress.output}{progress.outputTruncated ? `\n${t("commitHooks.outputTruncated")}` : ""}
+ )} +
+ ); +} + +function HookFailureDialog({ + rejection, + onClose, + onBypass, +}: { + rejection: NonNullable; + onClose: () => void; + onBypass: () => void; +}) { + const { t } = useTranslation("centre"); + const closeButtonRef = useRef(null); + + useEffect(() => { + closeButtonRef.current?.focus(); + }, []); + + return ( + <> +
+
+
{t("commitHooks.failedTitle")}
+
+ {t("commitHooks.failedDescription", {hook: rejection.hookName, exitStatus: rejection.exitStatus ?? t("commitHooks.unknownExitStatus")})} +
+ {rejection.bypassSupported &&
{t("commitHooks.bypassWarning")}
} + {rejection.output && ( +
{rejection.output}{rejection.outputTruncated ? `\n${t("commitHooks.outputTruncated")}` : ""}
+ )} +
+ + {rejection.bypassSupported && ( + + )} +
+
+ + ); +} + function AiConflictOperationFeedback({ operationId, batchProgress, @@ -429,7 +479,7 @@ export function StagingView({ selectedCommitAction, commitMessageRecommendedLength, allowCommitAndPush, onSelectCommitAction, onCommit, onConflictAcceptTheirs, onConflictAcceptOurs, onConflictResolveWithAi, getAiConflictEligibility, onConflictResolveAllWithAi, onCancelAiConflict, onOpenMergeTool, - stagingOperation, inlineOperation, isCommitting, lastCommitMessage, rowStriping, aiEnabled, aiConfigured, aiResolvingPath, + stagingOperation, inlineOperation, commitProgress, hookRejection, onHookRejectionClose, onHookRejectionBypass, isCommitting, lastCommitMessage, rowStriping, aiEnabled, aiConfigured, aiResolvingPath, aiConflictOperationId, aiConflictBatchProgress, aiConflictBatchFailure, onSkipAiConflictBatchFailure, onStopAiConflictBatchFailure, }: StagingViewProps) { @@ -602,11 +652,11 @@ export function StagingView({ const stagedTree = useMemo(() => buildFileTree(mergedStaged), [mergedStaged]); const unstagedTree = useMemo(() => buildFileTree(allUnstaged), [allUnstaged]); const stagedTreeRows = useMemo( - () => visibleTreeRows(stagedTree, "staged", expandedFolders, mergedStaged.length), + () => visibleFileTreeRows(stagedTree, expandedFolders, mergedStaged.length, (path) => folderStateKey("staged", path)), [stagedTree, expandedFolders, mergedStaged.length], ); const unstagedTreeRows = useMemo( - () => visibleTreeRows(unstagedTree, "unstaged", expandedFolders, allUnstaged.length), + () => visibleFileTreeRows(unstagedTree, expandedFolders, allUnstaged.length, (path) => folderStateKey("unstaged", path)), [unstagedTree, expandedFolders, allUnstaged.length], ); const stagingBusy = stagingOperation != null || aiResolvingPath !== null; @@ -648,7 +698,7 @@ export function StagingView({ if (rowStriping === "Off" || index % 2 === 0) return undefined; return rowStriping; }; - const renderTreeRow = (row: VisibleTreeRow, section: TreeSection) => { + const renderTreeRow = (row: VisibleFileTreeRow, section: TreeSection) => { const isStaged = section === "staged"; const selectedMap = isStaged ? selectedStaged : selectedUnstaged; const onSelectedChange = isStaged ? onSelectedStagedChange : onSelectedUnstagedChange; @@ -1016,7 +1066,9 @@ export function StagingView({ />
- {inlineOperation && inlineOperationIsCommit && } + {commitProgress + ? + : inlineOperation && inlineOperationIsCommit && } + {hookRejection && ( + {})} + onBypass={onHookRejectionBypass ?? (() => {})} + /> + )}
); } diff --git a/src/components/diff/DiffPanel.css b/src/components/diff/DiffPanel.css index 362d388..7784d84 100644 --- a/src/components/diff/DiffPanel.css +++ b/src/components/diff/DiffPanel.css @@ -167,7 +167,95 @@ .diff-panel__submodule-state--conflict { color: var(--red); } .diff-panel__commit-files { - padding: 8px 0; + padding-bottom: 8px; +} + +.diff-panel__commit-file-toolbar { + position: sticky; + top: 0; + z-index: 1; + padding: 8px 10px; + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-surface); +} + +.diff-panel__commit-file-search { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + min-height: 32px; + box-sizing: border-box; + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + color: var(--text-muted); +} + +.diff-panel__commit-file-search:focus-within { + border-color: var(--accent); + outline: 2px solid var(--focus-ring); + outline-offset: 1px; +} + +.diff-panel__commit-file-search-input { + flex: 1; + min-width: 0; + border: none; + background: none; + outline: none; + color: var(--text-primary); + font-family: var(--font-ui); + font-size: var(--font-size-sm); +} + +.diff-panel__commit-file-search-input::placeholder { + color: var(--text-secondary); +} + +.diff-panel__commit-folder-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + min-height: 32px; + color: var(--text-secondary-strong); +} + +.diff-panel__commit-folder-toggle { + width: 16px; + height: 16px; + padding: 0; + border: none; + background: none; + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.diff-panel__commit-folder-icon { + display: flex; + color: var(--text-muted); + flex-shrink: 0; +} + +.diff-panel__commit-folder-name { + flex: 1; + font-family: var(--font-ui); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.diff-panel__commit-folder-count { + font-size: var(--font-size-xs); + color: var(--text-muted); + flex-shrink: 0; } .diff-panel__commit-file-row { diff --git a/src/components/diff/DiffPanel.test.tsx b/src/components/diff/DiffPanel.test.tsx index 921dc7d..8dfe569 100644 --- a/src/components/diff/DiffPanel.test.tsx +++ b/src/components/diff/DiffPanel.test.tsx @@ -4,7 +4,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { getCommitDetails } from "../../api/commands"; import "../../i18n"; -import type { CommitDetails } from "../../types"; +import type { CommitDetails, CommitFileItem, RowStriping } from "../../types"; import { DiffPanel } from "./DiffPanel"; vi.mock("../../api/commands", () => ({ @@ -102,3 +102,287 @@ describe("DiffPanel commit details", () => { } }); }); + +function commitFile(path: string, status = "Modified"): CommitFileItem { + return { path, status }; +} + +function renderLog(options?: { + commitFiles?: CommitFileItem[]; + commitFilesLoading?: boolean; + selectedCommitHash?: string | null; + rowStriping?: RowStriping; +}) { + const onOpenCommitFileDiff = vi.fn(); + const view = render( + , + ); + + return { ...view, onOpenCommitFileDiff }; +} + +describe("DiffPanel commit file tree", () => { + it("groups nested files under a compact folder row", () => { + renderLog({ + commitFiles: [ + commitFile("src/components/Button.tsx"), + commitFile("src/components/Icon.tsx"), + ], + }); + + expect(screen.getByText("src/components")).toBeInTheDocument(); + expect(screen.getByText("2 files")).toBeInTheDocument(); + expect(screen.getByText("Button.tsx")).toBeInTheDocument(); + expect(screen.getByText("Icon.tsx")).toBeInTheDocument(); + expect(screen.queryByText("src/components/Button.tsx")).not.toBeInTheDocument(); + }); + + it("selects on click and opens the external diff on double-click using the full path", () => { + const { onOpenCommitFileDiff } = renderLog({ + commitFiles: [ + commitFile("src/components/Button.tsx"), + commitFile("src/components/Icon.tsx"), + ], + }); + + const button = screen.getByRole("button", { name: /Button\.tsx/ }); + fireEvent.click(button); + + expect(button).toHaveClass("diff-panel__commit-file-row--selected"); + expect(onOpenCommitFileDiff).not.toHaveBeenCalled(); + + fireEvent.doubleClick(button); + + expect(onOpenCommitFileDiff).toHaveBeenCalledWith("src/components/Button.tsx"); + expect(button).toHaveAttribute("title", "src/components/Button.tsx"); + }); + + it("hides nested files when a folder is collapsed and shows them again when expanded", () => { + renderLog({ + commitFiles: [ + commitFile("src/components/Button.tsx"), + commitFile("src/components/Icon.tsx"), + ], + }); + + fireEvent.click(screen.getByLabelText("Collapse src/components")); + + expect(screen.queryByText("Button.tsx")).not.toBeInTheDocument(); + expect(screen.queryByText("Icon.tsx")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText("Expand src/components")); + + expect(screen.getByText("Button.tsx")).toBeInTheDocument(); + expect(screen.getByText("Icon.tsx")).toBeInTheDocument(); + }); + + it("keeps status letters on file rows", () => { + renderLog({ + commitFiles: [ + commitFile("src/components/Button.tsx", "Added"), + commitFile("README.md", "Deleted"), + ], + }); + + expect(screen.getByText("A")).toBeInTheDocument(); + expect(screen.getByText("D")).toBeInTheDocument(); + }); + + it("stripes only visible file rows and recalculates after collapse", () => { + renderLog({ + commitFiles: [ + commitFile("lib/A.ts"), + commitFile("B.ts"), + commitFile("C.ts"), + ], + rowStriping: "Subtle", + }); + + expect(screen.getByRole("button", { name: /A\.ts/ })).not.toHaveClass("diff-panel__commit-file-row--striped-subtle"); + expect(screen.getByRole("button", { name: /B\.ts/ })).toHaveClass("diff-panel__commit-file-row--striped-subtle"); + expect(screen.getByRole("button", { name: /C\.ts/ })).not.toHaveClass("diff-panel__commit-file-row--striped-subtle"); + expect(screen.getByText("lib").closest(".diff-panel__commit-folder-row")).not.toHaveClass( + "diff-panel__commit-file-row--striped-subtle", + ); + + fireEvent.click(screen.getByLabelText("Collapse lib")); + + expect(screen.queryByText("A.ts")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /B\.ts/ })).not.toHaveClass("diff-panel__commit-file-row--striped-subtle"); + expect(screen.getByRole("button", { name: /C\.ts/ })).toHaveClass("diff-panel__commit-file-row--striped-subtle"); + }); + + it("shows root-level files without a folder row", () => { + renderLog({ + commitFiles: [commitFile("README.md")], + }); + + expect(screen.getByText("README.md")).toBeInTheDocument(); + expect(screen.queryByLabelText(/Collapse /)).not.toBeInTheDocument(); + }); + + it("clears folder expansion and file selection when the commit hash changes", () => { + const { rerender, onOpenCommitFileDiff } = renderLog({ + commitFiles: [ + commitFile("src/components/Button.tsx"), + commitFile("src/components/Icon.tsx"), + ], + }); + + fireEvent.click(screen.getByRole("button", { name: /Button\.tsx/ })); + fireEvent.click(screen.getByLabelText("Collapse src/components")); + expect(screen.queryByText("Button.tsx")).not.toBeInTheDocument(); + + rerender( + , + ); + + const button = screen.getByRole("button", { name: /Button\.tsx/ }); + expect(button).not.toHaveClass("diff-panel__commit-file-row--selected"); + expect(screen.getByText("Icon.tsx")).toBeInTheDocument(); + }); + + it("keeps loading and empty commit states unchanged", () => { + const { rerender } = renderLog({ commitFilesLoading: true }); + + expect(screen.getByText("Loading commit files...")).toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.getByText("Select a commit to view changed files")).toBeInTheDocument(); + }); + + it("filters commit files by path and keeps matching folders", () => { + renderLog({ + commitFiles: [ + commitFile("src/components/Button.tsx"), + commitFile("src/components/Icon.tsx"), + commitFile("README.md"), + ], + }); + + fireEvent.change(screen.getByLabelText("Search changed files..."), { + target: { value: "button" }, + }); + + expect(screen.getByText("Button.tsx")).toBeInTheDocument(); + expect(screen.getByText("src/components")).toBeInTheDocument(); + expect(screen.queryByText("Icon.tsx")).not.toBeInTheDocument(); + expect(screen.queryByText("README.md")).not.toBeInTheDocument(); + }); + + it("shows an empty state when no commit files match the search", () => { + renderLog({ + commitFiles: [commitFile("src/components/Button.tsx")], + }); + + fireEvent.change(screen.getByLabelText("Search changed files..."), { + target: { value: "missing" }, + }); + + expect(screen.getByText("No files match this search")).toBeInTheDocument(); + expect(screen.queryByText("Button.tsx")).not.toBeInTheDocument(); + }); + + it("clears the file search when the commit hash changes", () => { + const { rerender, onOpenCommitFileDiff } = renderLog({ + commitFiles: [ + commitFile("src/components/Button.tsx"), + commitFile("README.md"), + ], + }); + + fireEvent.change(screen.getByLabelText("Search changed files..."), { + target: { value: "README" }, + }); + expect(screen.queryByText("Button.tsx")).not.toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.getByLabelText("Search changed files...")).toHaveValue(""); + expect(screen.getByText("Button.tsx")).toBeInTheDocument(); + expect(screen.getByText("README.md")).toBeInTheDocument(); + }); +}); diff --git a/src/components/diff/DiffPanel.tsx b/src/components/diff/DiffPanel.tsx index 3c88e0c..013defc 100644 --- a/src/components/diff/DiffPanel.tsx +++ b/src/components/diff/DiffPanel.tsx @@ -1,9 +1,10 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { Decoration, Diff, Hunk, type ChangeData, type DiffType, type HunkData, type ViewType } from "react-diff-view"; -import { CloseIcon, FileIcon } from "../icons"; +import { ChevDownIcon, ChevRightIcon, CloseIcon, FileIcon, FolderIcon, SearchIcon } from "../icons"; import { StageHunkIcon } from "../icons"; import type { CommitDetails, CommitFileItem, FileDiff, RowStriping, SubmoduleStatus } from "../../types"; +import { buildFileTree, visibleFileTreeRows } from "../../utils/fileTree"; import { getCommitDetails } from "../../api/commands"; import type { CentreTab } from "../centre/CentrePanel"; import "react-diff-view/style/index.css"; @@ -216,6 +217,8 @@ export function DiffPanel({ const { t } = useTranslation("diffPanel"); const [viewType, setViewType] = React.useState("unified"); const [selectedCommitFile, setSelectedCommitFile] = React.useState(null); + const [expandedFolders, setExpandedFolders] = React.useState>({}); + const [commitFileQuery, setCommitFileQuery] = React.useState(""); const [detailsPopover, setDetailsPopover] = React.useState<{ rect: DOMRect; data: CommitDetails } | null>(null); const [detailsLoading, setDetailsLoading] = React.useState(false); @@ -225,9 +228,42 @@ export function DiffPanel({ React.useEffect(() => { setSelectedCommitFile(null); + setExpandedFolders({}); + setCommitFileQuery(""); setDetailsPopover(null); }, [selectedCommitHash]); + const filteredCommitFiles = React.useMemo(() => { + const query = commitFileQuery.trim().toLowerCase(); + if (!query) return commitFiles; + return commitFiles.filter((file) => file.path.toLowerCase().includes(query)); + }, [commitFiles, commitFileQuery]); + + const commitTreeRows = React.useMemo(() => { + const treeItems = filteredCommitFiles.map((file) => ({ + path: file.path, + status: file.status, + additions: null, + deletions: null, + })); + return visibleFileTreeRows( + buildFileTree(treeItems), + commitFileQuery.trim() ? {} : expandedFolders, + filteredCommitFiles.length, + (path) => path, + ); + }, [commitFileQuery, filteredCommitFiles, expandedFolders]); + + const toggleFolderExpanded = (path: string) => { + setExpandedFolders((prev) => { + const currentRow = commitTreeRows.find((row) => row.type === "directory" && row.node.path === path); + const currentExpanded = currentRow?.type === "directory" + ? currentRow.expanded + : prev[path] ?? true; + return { ...prev, [path]: !currentExpanded }; + }); + }; + const hasSelectedFile = mode === "changes" && !!selectedFile; const hasSelectedSubmodule = mode === "changes" && !!selectedSubmodule; const currentDiff = @@ -397,20 +433,73 @@ export function DiffPanel({
{t("placeholders.loadingCommitFiles")}
) : commitFiles.length > 0 ? (
- {commitFiles.map((file, index) => { - const rowStripe = striped(index); +
+ +
+ {filteredCommitFiles.length === 0 ? ( +
{t("placeholders.noMatchingCommitFiles")}
+ ) : commitTreeRows.map((row) => { + const indent = row.depth > 0 ? { paddingLeft: 8 + row.depth * 18 } : undefined; + + if (row.type === "directory") { + return ( +
+ {row.node.children.length > 0 && ( + + )} + + + + {row.node.name} + + {t("fileCount", { ns: "common", count: row.node.fileCount })} + +
+ ); + } + + const file = row.node.file; + const rowStripe = striped(row.fileIndex); return ( ); })} diff --git a/src/components/settings/SettingsWindow.tsx b/src/components/settings/SettingsWindow.tsx index 1a026cc..14ae2cc 100644 --- a/src/components/settings/SettingsWindow.tsx +++ b/src/components/settings/SettingsWindow.tsx @@ -357,6 +357,7 @@ export function SettingsWindow() { const [commitDateMode, setCommitDateMode] = useState("AuthorDate"); const [commitMessageRecommendedLength, setCommitMessageRecommendedLength] = useState(String(DEFAULT_COMMIT_MESSAGE_RECOMMENDED_LENGTH)); const [pushFollowTags, setPushFollowTags] = useState(false); + const [autoFetchIntervalMinutes, setAutoFetchIntervalMinutes] = useState(0); const [autoCheckForUpdatesOnLaunch, setAutoCheckForUpdatesOnLaunch] = useState(true); const [autoInstallUpdates, setAutoInstallUpdates] = useState(false); const [updateEndpoint, setUpdateEndpointState] = useState(DEFAULT_UPDATE_ENDPOINT); @@ -614,6 +615,7 @@ export function SettingsWindow() { setCommitDateMode(settings.commitDateMode ?? "AuthorDate"); setCommitMessageRecommendedLength(String(settings.commitMessageRecommendedLength ?? DEFAULT_COMMIT_MESSAGE_RECOMMENDED_LENGTH)); setPushFollowTags(settings.pushFollowTags ?? false); + setAutoFetchIntervalMinutes(settings.autoFetchIntervalMinutes ?? 0); setAutoCheckForUpdatesOnLaunch(settings.autoCheckForUpdatesOnLaunch ?? true); setAutoInstallUpdates(settings.autoInstallUpdates ?? false); setUpdateEndpointState(settings.updateEndpoint ?? DEFAULT_UPDATE_ENDPOINT); @@ -1161,6 +1163,7 @@ export function SettingsWindow() { await invoke("set_commit_date_mode", {commitDateMode}); await invoke("set_commit_message_recommended_length", {commitMessageRecommendedLength: savedCommitMessageRecommendedLength}); await invoke("set_push_follow_tags", {pushFollowTags}); + await invoke("set_auto_fetch_interval_minutes", {autoFetchIntervalMinutes}); await invoke("set_auto_check_for_updates_on_launch", {autoCheckForUpdatesOnLaunch}); await invoke("set_auto_install_updates", {autoInstallUpdates}); await setUpdateEndpoint(updateEndpoint); @@ -2796,6 +2799,22 @@ export function SettingsWindow() {
{t("labels.gitGroupGitmunBehaviour")}
+
+ + +
{t("notes.autoFetch")}
+
+