diff --git a/src-tauri/src/commands/repo.rs b/src-tauri/src/commands/repo.rs index 372fc6d..e7469f6 100644 --- a/src-tauri/src/commands/repo.rs +++ b/src-tauri/src/commands/repo.rs @@ -2,11 +2,13 @@ use crate::git::types::LinuxTerminalEmulator; use crate::git::types::{ CloneRequest, CommitDetails, CommitDetailsRequest, CommitFileItem, CommitFilesRequest, - CommitMarkers, CommitRequest, DiffRequest, ExportPatchRequest, ExternalDiffRequest, - FetchRequest, FileDiff, FileRequest, GitIdentity, HunkStageRequest, IdentityRequest, - ImportPatchRequest, NumstatRequest, NumstatResult, OperationResult, PullAnalysis, - PullStrategyRequest, PushRequest, PushResult, RepoRequest, RepoStatus, SetIdentityRequest, - StageFilesRequest, StashEntry, StashPushRequest, StashRequest, SubmoduleActionRequest, + CommitMarkers, CommitMessageRecovery, CommitRequest, DiffRequest, ExportCommitPatchRequest, + ExportPatchRequest, + ExternalDiffRequest, FetchRequest, FileDiff, FileRequest, GitIdentity, HunkStageRequest, + IdentityRequest, ImportPatchRequest, NumstatRequest, NumstatResult, OperationResult, + PullAnalysis, PullStrategyRequest, PushRequest, PushResult, RepoRequest, RepoStatus, + SetIdentityRequest, SshAllowedSignerStatus, StageFilesRequest, StashEntry, StashPushRequest, + StashRequest, SubmoduleActionRequest, }; use crate::{AppState, CloneCancelFlag, configure_command}; use serde::{Deserialize, Serialize}; @@ -724,6 +726,17 @@ pub fn export_patch_file( .map_err(|error| error.to_string()) } +#[tauri::command] +pub fn export_commit_patch_file( + request: ExportCommitPatchRequest, + state: tauri::State<'_, AppState>, +) -> Result { + state + .git_service + .export_commit_patch_file(request) + .map_err(|error| error.to_string()) +} + #[tauri::command] pub fn get_repo_diff_tool( request: RepoRequest, @@ -801,24 +814,39 @@ pub async fn get_numstat( } #[tauri::command] -pub fn stage_files( +pub async fn stage_files( request: StageFilesRequest, - state: tauri::State<'_, AppState>, + app: tauri::AppHandle, ) -> Result { - state - .git_service - .stage_files(request) - .map_err(|error| error.to_string()) + tauri::async_runtime::spawn_blocking(move || { + app.state::().git_service.stage_files(request) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|error| error.to_string()) } #[tauri::command] -pub fn commit_changes( +pub async fn commit_changes( request: CommitRequest, - state: tauri::State<'_, AppState>, + app: tauri::AppHandle, ) -> Result { + tauri::async_runtime::spawn_blocking(move || { + app.state::().git_service.commit_changes(request) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub fn get_commit_message_recovery( + request: RepoRequest, + state: tauri::State<'_, AppState>, +) -> Result, String> { state .git_service - .commit_changes(request) + .get_commit_message_recovery(request) .map_err(|error| error.to_string()) } @@ -833,36 +861,42 @@ pub async fn get_diff(request: DiffRequest, app: tauri::AppHandle) -> Result, + app: tauri::AppHandle, ) -> Result { - state - .git_service - .unstage_file(request) - .map_err(|error| error.to_string()) + tauri::async_runtime::spawn_blocking(move || { + app.state::().git_service.unstage_file(request) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|error| error.to_string()) } #[tauri::command] -pub fn unstage_all( +pub async fn unstage_all( request: RepoRequest, - state: tauri::State<'_, AppState>, + app: tauri::AppHandle, ) -> Result { - state - .git_service - .unstage_all(request) - .map_err(|error| error.to_string()) + tauri::async_runtime::spawn_blocking(move || { + app.state::().git_service.unstage_all(request) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|error| error.to_string()) } #[tauri::command] -pub fn stage_all( +pub async fn stage_all( request: RepoRequest, - state: tauri::State<'_, AppState>, + app: tauri::AppHandle, ) -> Result { - state - .git_service - .stage_all(request) - .map_err(|error| error.to_string()) + tauri::async_runtime::spawn_blocking(move || { + app.state::().git_service.stage_all(request) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|error| error.to_string()) } #[tauri::command] @@ -1045,6 +1079,28 @@ pub fn set_identity( .map_err(|error| error.to_string()) } +#[tauri::command] +pub fn get_ssh_allowed_signer_status( + request: IdentityRequest, + state: tauri::State<'_, AppState>, +) -> Result { + state + .git_service + .get_ssh_allowed_signer_status(request) + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub fn add_ssh_signing_key_to_allowed_signers( + request: IdentityRequest, + state: tauri::State<'_, AppState>, +) -> Result { + state + .git_service + .add_ssh_signing_key_to_allowed_signers(request) + .map_err(|error| error.to_string()) +} + #[tauri::command] pub async fn push_changes( request: PushRequest, diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index d5b72f6..baff225 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -315,14 +315,8 @@ pub fn get_app_update_channel(state: tauri::State<'_, AppState>) -> AppUpdateCha } #[cfg(target_os = "windows")] if crate::is_msix_build() { - if state - .git_service - .get_settings() - .enable_update_with_ms_store_flow - { - return AppUpdateChannel::MicrosoftStore; - } - return AppUpdateChannel::SystemManaged; + let _ = &state; + return AppUpdateChannel::MicrosoftStore; } AppUpdateChannel::SelfManaged } diff --git a/src-tauri/src/commands/store_update.rs b/src-tauri/src/commands/store_update.rs index 016cfed..586b2bf 100644 --- a/src-tauri/src/commands/store_update.rs +++ b/src-tauri/src/commands/store_update.rs @@ -1,5 +1,4 @@ use serde::Serialize; -use tauri::ipc::Channel; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -7,62 +6,6 @@ pub struct MicrosoftStoreUpdate { current_version: String, package_count: u32, mandatory: bool, - queue_status: Option, -} - -#[derive(Clone, Debug, Serialize)] -pub enum MicrosoftStoreUpdateStatus { - Completed, - Canceled, - OtherError, - ErrorLowBattery, - ErrorWifiRecommended, - ErrorWifiRequired, - Unknown, -} - -#[derive(Clone, Debug, Serialize)] -pub enum MicrosoftStoreQueueState { - Active, - Paused, - Completed, - Canceled, - Error, - Unknown, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MicrosoftStoreUpdateProgress { - package_download_progress: f64, - total_download_progress: f64, - package_bytes_downloaded: u64, - package_download_size_in_bytes: u64, - package_update_state: MicrosoftStoreUpdateStatus, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MicrosoftStoreQueueStatus { - state: MicrosoftStoreQueueState, - extended_state: String, - progress: Option, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(tag = "event", content = "data")] -pub enum MicrosoftStoreUpdateEvent { - #[serde(rename = "Progress")] - Progress(MicrosoftStoreUpdateProgress), - #[serde(rename = "QueueStatus")] - QueueStatus(MicrosoftStoreQueueStatus), -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MicrosoftStoreUpdateResult { - status: MicrosoftStoreUpdateStatus, - queue_status: Option, } #[cfg(any(debug_assertions, test))] @@ -99,40 +42,16 @@ fn local_test_check() -> Option, String>> { if mode == "none" { return Some(Ok(None)); } - if matches!( - mode.as_str(), - "" | "1" - | "true" - | "available" - | "mandatory" - | "completed" - | "cancelled" - | "canceled" - | "deferred" - | "error" - | "other-error" - | "low-battery" - | "wifi-recommended" - | "wifi-required" - | "progress-download" - | "progress-install" - | "queue-active" - | "queue-paused" - | "queue-completed" - | "queue-cancelled" - | "queue-canceled" - | "queue-error" - ) { + if matches!(mode.as_str(), "" | "1" | "true" | "available" | "mandatory") { return Some(Ok(Some(MicrosoftStoreUpdate { current_version: env!("CARGO_PKG_VERSION").to_string(), package_count: 1, mandatory: mode == "mandatory", - queue_status: local_test_queue_status(&mode), }))); } Some(Err(format!( - "{LOCAL_TEST_ENV} must be one of available, mandatory, none, completed, cancelled, error, low-battery, wifi-recommended, wifi-required, progress-download, progress-install, queue-active, queue-paused, queue-completed, queue-cancelled, or queue-error." + "{LOCAL_TEST_ENV} must be one of available, mandatory, or none." ))) } @@ -142,42 +61,7 @@ fn local_test_check() -> Option, String>> { } #[cfg(any(debug_assertions, test))] -fn local_test_progress(mode: &str) -> Option { - let total_download_progress: f64 = match mode { - "progress-download" | "queue-active" => 0.4, - "progress-install" | "queue-paused" => 0.85, - _ => return None, - }; - Some(MicrosoftStoreUpdateProgress { - package_download_progress: total_download_progress.min(0.8) / 0.8, - total_download_progress, - package_bytes_downloaded: (total_download_progress * 1_000.0) as u64, - package_download_size_in_bytes: 1_000, - package_update_state: MicrosoftStoreUpdateStatus::Unknown, - }) -} - -#[cfg(any(debug_assertions, test))] -fn local_test_queue_status(mode: &str) -> Option { - let state = match mode { - "queue-active" => MicrosoftStoreQueueState::Active, - "queue-paused" => MicrosoftStoreQueueState::Paused, - "queue-completed" => MicrosoftStoreQueueState::Completed, - "queue-cancelled" | "queue-canceled" => MicrosoftStoreQueueState::Canceled, - "queue-error" => MicrosoftStoreQueueState::Error, - _ => return None, - }; - Some(MicrosoftStoreQueueStatus { - state, - extended_state: mode.to_string(), - progress: local_test_progress(mode), - }) -} - -#[cfg(any(debug_assertions, test))] -fn local_test_request( - on_event: Channel, -) -> Option> { +fn local_test_open() -> Option> { if !is_local_test_enabled() { return None; } @@ -186,82 +70,40 @@ fn local_test_request( Ok(mode) => mode, Err(error) => return Some(Err(error)), }; - if let Some(progress) = local_test_progress(&mode) { - let _ = on_event.send(MicrosoftStoreUpdateEvent::Progress(progress)); - } - if let Some(queue_status) = local_test_queue_status(&mode) { - let _ = on_event.send(MicrosoftStoreUpdateEvent::QueueStatus(queue_status.clone())); - let status = match queue_status.state { - MicrosoftStoreQueueState::Completed => MicrosoftStoreUpdateStatus::Completed, - MicrosoftStoreQueueState::Canceled => MicrosoftStoreUpdateStatus::Canceled, - MicrosoftStoreQueueState::Error => MicrosoftStoreUpdateStatus::OtherError, - MicrosoftStoreQueueState::Active | MicrosoftStoreQueueState::Paused => { - MicrosoftStoreUpdateStatus::Unknown - } - MicrosoftStoreQueueState::Unknown => MicrosoftStoreUpdateStatus::Unknown, - }; - return Some(Ok(MicrosoftStoreUpdateResult { - status, - queue_status: Some(queue_status), - })); + if matches!( + mode.as_str(), + "" | "1" | "true" | "available" | "mandatory" | "none" + ) { + Some(Ok(())) + } else { + Some(Err(format!( + "{LOCAL_TEST_ENV} must be one of available, mandatory, or none." + ))) } - - let status = match mode.as_str() { - "cancelled" | "canceled" | "deferred" => MicrosoftStoreUpdateStatus::Canceled, - "error" | "other-error" => MicrosoftStoreUpdateStatus::OtherError, - "low-battery" => MicrosoftStoreUpdateStatus::ErrorLowBattery, - "wifi-recommended" => MicrosoftStoreUpdateStatus::ErrorWifiRecommended, - "wifi-required" => MicrosoftStoreUpdateStatus::ErrorWifiRequired, - "progress-download" | "progress-install" => MicrosoftStoreUpdateStatus::Completed, - "" | "1" | "true" | "available" | "mandatory" | "none" | "completed" => { - MicrosoftStoreUpdateStatus::Completed - } - _ => { - return Some(Err(format!( - "{LOCAL_TEST_ENV} must be one of available, mandatory, none, completed, cancelled, error, low-battery, wifi-recommended, wifi-required, progress-download, progress-install, queue-active, queue-paused, queue-completed, queue-cancelled, or queue-error." - ))); - } - }; - - Some(Ok(MicrosoftStoreUpdateResult { - status, - queue_status: None, - })) } #[cfg(not(any(debug_assertions, test)))] -fn local_test_request( - _on_event: Channel, -) -> Option> { +fn local_test_open() -> Option> { None } #[cfg(target_os = "windows")] mod platform { - use super::{ - MicrosoftStoreQueueState, MicrosoftStoreQueueStatus, MicrosoftStoreUpdate, - MicrosoftStoreUpdateEvent, MicrosoftStoreUpdateProgress, MicrosoftStoreUpdateResult, - MicrosoftStoreUpdateStatus, - }; - use std::sync::mpsc; - use tauri::{Manager, WebviewWindow, ipc::Channel}; + use super::MicrosoftStoreUpdate; + use tauri::{Manager, WebviewWindow}; use windows::{ ApplicationModel::{Package, PackageVersion}, - Services::Store::{ - StoreContext, StorePackageUpdateResult, StorePackageUpdateState, - StorePackageUpdateStatus, StoreQueueItemExtendedState, StoreQueueItemState, - }, + Services::Store::StoreContext, Win32::Foundation::HWND, Win32::UI::{ - Shell::{IInitializeWithWindow, ShellExecuteW}, + Shell::ShellExecuteW, WindowsAndMessaging::SW_SHOWNORMAL, }, - core::{Interface, Ref, w}, - }; - use windows_future::{ - AsyncOperationProgressHandler, AsyncOperationWithProgressCompletedHandler, + core::w, }; + const STORE_OPEN_FAILED: &str = "GITMUN_ERROR_MICROSOFT_STORE_OPEN_FAILED"; + fn version_string(version: PackageVersion) -> String { format!( "{}.{}.{}.{}", @@ -276,142 +118,32 @@ mod platform { Ok(version_string(version)) } - fn update_status(state: StorePackageUpdateState) -> MicrosoftStoreUpdateStatus { - if state == StorePackageUpdateState::Completed { - MicrosoftStoreUpdateStatus::Completed - } else if state == StorePackageUpdateState::Canceled { - MicrosoftStoreUpdateStatus::Canceled - } else if state == StorePackageUpdateState::OtherError { - MicrosoftStoreUpdateStatus::OtherError - } else if state == StorePackageUpdateState::ErrorLowBattery { - MicrosoftStoreUpdateStatus::ErrorLowBattery - } else if state == StorePackageUpdateState::ErrorWiFiRecommended { - MicrosoftStoreUpdateStatus::ErrorWifiRecommended - } else if state == StorePackageUpdateState::ErrorWiFiRequired { - MicrosoftStoreUpdateStatus::ErrorWifiRequired - } else { - MicrosoftStoreUpdateStatus::Unknown - } - } - - fn progress_status(status: StorePackageUpdateStatus) -> MicrosoftStoreUpdateProgress { - MicrosoftStoreUpdateProgress { - package_download_progress: status.PackageDownloadProgress, - total_download_progress: status.TotalDownloadProgress, - package_bytes_downloaded: status.PackageBytesDownloaded, - package_download_size_in_bytes: status.PackageDownloadSizeInBytes, - package_update_state: update_status(status.PackageUpdateState), - } - } - - fn queue_state(state: StoreQueueItemState) -> MicrosoftStoreQueueState { - if state == StoreQueueItemState::Active { - MicrosoftStoreQueueState::Active - } else if state == StoreQueueItemState::Paused { - MicrosoftStoreQueueState::Paused - } else if state == StoreQueueItemState::Completed { - MicrosoftStoreQueueState::Completed - } else if state == StoreQueueItemState::Canceled { - MicrosoftStoreQueueState::Canceled - } else if state == StoreQueueItemState::Error { - MicrosoftStoreQueueState::Error - } else { - MicrosoftStoreQueueState::Unknown - } - } - - fn queue_extended_state(state: StoreQueueItemExtendedState) -> String { - if state == StoreQueueItemExtendedState::ActivePending { - "ActivePending" - } else if state == StoreQueueItemExtendedState::ActiveStarting { - "ActiveStarting" - } else if state == StoreQueueItemExtendedState::ActiveAcquiringLicense { - "ActiveAcquiringLicense" - } else if state == StoreQueueItemExtendedState::ActiveDownloading { - "ActiveDownloading" - } else if state == StoreQueueItemExtendedState::ActiveRestoringData { - "ActiveRestoringData" - } else if state == StoreQueueItemExtendedState::ActiveInstalling { - "ActiveInstalling" - } else if state == StoreQueueItemExtendedState::Completed { - "Completed" - } else if state == StoreQueueItemExtendedState::Canceled { - "Canceled" - } else if state == StoreQueueItemExtendedState::Paused { - "Paused" - } else if state == StoreQueueItemExtendedState::Error { - "Error" - } else if state == StoreQueueItemExtendedState::PausedPackagesInUse { - "PausedPackagesInUse" - } else if state == StoreQueueItemExtendedState::PausedLowBattery { - "PausedLowBattery" - } else if state == StoreQueueItemExtendedState::PausedWiFiRecommended { - "PausedWiFiRecommended" - } else if state == StoreQueueItemExtendedState::PausedWiFiRequired { - "PausedWiFiRequired" - } else if state == StoreQueueItemExtendedState::PausedReadyToInstall { - "PausedReadyToInstall" - } else { - "Unknown" - } - .to_string() - } - - fn queue_status(context: &StoreContext) -> Result, String> { - let items = context - .GetAssociatedStoreQueueItemsAsync() - .map_err(|error| error.to_string())? - .get() - .map_err(|error| error.to_string())?; - let item_count = items.Size().map_err(|error| error.to_string())?; - for index in 0..item_count { - let item = items.GetAt(index).map_err(|error| error.to_string())?; - let status = item.GetCurrentStatus().map_err(|error| error.to_string())?; - let state = queue_state( - status - .PackageInstallState() - .map_err(|error| error.to_string())?, - ); - if matches!(state, MicrosoftStoreQueueState::Unknown) { - continue; - } - let extended_state = status - .PackageInstallExtendedState() - .map(queue_extended_state) - .unwrap_or_else(|_| "Unknown".to_string()); - let progress = status.UpdateStatus().ok().map(progress_status); - return Ok(Some(MicrosoftStoreQueueStatus { - state, - extended_state, - progress, - })); - } - Ok(None) - } - - fn open_store_updates_page(hwnd: HWND) { - let _ = unsafe { + fn open_store_page(hwnd: HWND) -> Result<(), String> { + let result = unsafe { ShellExecuteW( Some(hwnd), w!("open"), - w!("ms-windows-store://downloadsandupdates"), + w!("ms-windows-store://pdp/?ProductId=9NBVNCKH5J9V"), None, None, SW_SHOWNORMAL, ) }; + if result.0 as isize <= 32 { + return Err(STORE_OPEN_FAILED.to_string()); + } + Ok(()) } pub async fn check() -> Result, String> { let context = StoreContext::GetDefault().map_err(|error| error.to_string())?; - let queue_status = queue_status(&context)?; let updates = context .GetAppAndOptionalStorePackageUpdatesAsync() .map_err(|error| error.to_string())? .await .map_err(|error| error.to_string())?; let package_count = updates.Size().map_err(|error| error.to_string())?; - if package_count == 0 && queue_status.is_none() { + if package_count == 0 { return Ok(None); } @@ -425,114 +157,17 @@ mod platform { current_version: current_version()?, package_count, mandatory, - queue_status, })) } - pub async fn request( - window: WebviewWindow, - on_event: Channel, - ) -> Result { + pub async fn open_update_page(window: WebviewWindow) -> Result<(), String> { let hwnd_value = window.hwnd().map_err(|error| error.to_string())?.0 as isize; - let (sender, receiver) = mpsc::channel(); + let (sender, receiver) = std::sync::mpsc::channel(); window .app_handle() .run_on_main_thread(move || { - let mut sender = Some(sender); - let result = (|| { - open_store_updates_page(HWND(hwnd_value as _)); - let context = StoreContext::GetDefault().map_err(|error| error.to_string())?; - let initialise: IInitializeWithWindow = - context.cast().map_err(|error| error.to_string())?; - unsafe { - initialise - .Initialize(HWND(hwnd_value as _)) - .map_err(|error| error.to_string())?; - } - let updates = context - .GetAppAndOptionalStorePackageUpdatesAsync() - .map_err(|error| error.to_string())? - .get() - .map_err(|error| error.to_string())?; - let package_count = updates.Size().map_err(|error| error.to_string())?; - if package_count == 0 { - return Ok(MicrosoftStoreUpdateResult { - status: MicrosoftStoreUpdateStatus::Completed, - queue_status: queue_status(&context)?, - }); - } - let operation = context - .RequestDownloadAndInstallStorePackageUpdatesAsync(&updates) - .map_err(|error| error.to_string())?; - operation - .SetProgress(&AsyncOperationProgressHandler::new({ - let on_event = on_event; - move |_operation, progress: Ref<'_, StorePackageUpdateStatus>| { - if let Some(progress) = progress.as_ref() { - let _ = on_event.send(MicrosoftStoreUpdateEvent::Progress( - progress_status(progress.clone()), - )); - } - Ok(()) - } - })) - .map_err(|error| error.to_string())?; - let completion_sender = sender.take().ok_or_else(|| { - "Microsoft Store update callback was already used.".to_string() - })?; - operation - .SetCompleted(&AsyncOperationWithProgressCompletedHandler::new( - move |operation, _status| { - let result = operation - .ok() - .and_then(|operation| operation.GetResults()) - .and_then(|result: StorePackageUpdateResult| { - let status = update_status(result.OverallState()?); - let queue_status = - result.StoreQueueItems().ok().and_then(|items| { - let item = items.GetAt(0).ok()?; - let status = item.GetCurrentStatus().ok()?; - Some(MicrosoftStoreQueueStatus { - state: queue_state( - status.PackageInstallState().ok()?, - ), - extended_state: status - .PackageInstallExtendedState() - .map(queue_extended_state) - .unwrap_or_else(|_| "Unknown".to_string()), - progress: status - .UpdateStatus() - .ok() - .map(progress_status), - }) - }); - Ok(MicrosoftStoreUpdateResult { - status, - queue_status, - }) - }) - .map_err(|error| error.to_string()); - let _ = completion_sender.send(result); - Ok(()) - }, - )) - .map_err(|error| error.to_string())?; - Ok(MicrosoftStoreUpdateResult { - status: MicrosoftStoreUpdateStatus::Unknown, - queue_status: None, - }) - })(); - if !matches!( - result, - Ok(MicrosoftStoreUpdateResult { - status: MicrosoftStoreUpdateStatus::Unknown, - .. - }) - ) { - if let Some(sender) = sender.take() { - let _ = sender.send(result); - } - } + let result = open_store_page(HWND(hwnd_value as _)); + let _ = sender.send(result); }) .map_err(|error| error.to_string())?; @@ -550,14 +185,10 @@ pub async fn check_microsoft_store_update( #[cfg(target_os = "windows")] { - if !crate::is_msix_build() - || !state - .git_service - .get_settings() - .enable_update_with_ms_store_flow - { + if !crate::is_msix_build() { return Err("Microsoft Store update flow is disabled.".to_string()); } + let _ = state; platform::check().await } #[cfg(not(target_os = "windows"))] @@ -568,32 +199,26 @@ pub async fn check_microsoft_store_update( } #[tauri::command] -pub async fn request_microsoft_store_update( +pub async fn open_microsoft_store_update_page( window: tauri::WebviewWindow, state: tauri::State<'_, crate::AppState>, - on_event: Channel, -) -> Result { - if let Some(result) = local_test_request(on_event.clone()) { +) -> Result<(), String> { + if let Some(result) = local_test_open() { return result; } #[cfg(target_os = "windows")] { - if !crate::is_msix_build() - || !state - .git_service - .get_settings() - .enable_update_with_ms_store_flow - { + if !crate::is_msix_build() { return Err("Microsoft Store update flow is disabled.".to_string()); } - platform::request(window, on_event).await + let _ = state; + platform::open_update_page(window).await } #[cfg(not(target_os = "windows"))] { let _ = window; let _ = state; - let _ = on_event; Err("Microsoft Store updates are only available on Windows.".to_string()) } } diff --git a/src-tauri/src/config_file.rs b/src-tauri/src/config_file.rs index 0f43b9a..7000d9e 100644 --- a/src-tauri/src/config_file.rs +++ b/src-tauri/src/config_file.rs @@ -3,7 +3,6 @@ use std::path::Path; use crate::git::types::Settings; const TEMPLATE: &str = include_str!("../config.example.toml"); -const MANUAL_CONFIG_KEYS: &[&str] = &["enableUpdateWithMSStoreFlow"]; /// Load settings from config.toml, migrating from config.json if needed. /// @@ -126,7 +125,7 @@ fn apply_settings_to_doc(doc: &mut toml_edit::DocumentMut, settings: &Settings) *v = new_val.clone(); } table.insert_formatted(template_key, new_item); - } else if !MANUAL_CONFIG_KEYS.contains(&key) { + } else { table.insert(key, toml_edit::value(new_val.clone())); } } @@ -214,7 +213,7 @@ mod tests { } #[test] - fn load_toml_populates_manual_ms_store_update_flow_config() { + fn load_toml_ignores_old_ms_store_update_flow_config() { let dir = TempDir::new().unwrap(); let toml_path = dir.path().join("config.toml"); let json_path = dir.path().join("config.json"); @@ -223,7 +222,11 @@ mod tests { let (settings, should_persist) = load_or_migrate(&toml_path, &json_path); assert!(!should_persist); - assert!(settings.enable_update_with_ms_store_flow); + assert_eq!( + settings.backend_mode, + crate::git::types::BackendMode::Default + ); + assert!(!settings.show_result_log); } #[test] @@ -438,16 +441,13 @@ mod tests { } #[test] - fn persist_preserves_manual_ms_store_update_flow_config() { + fn persist_leaves_old_ms_store_update_flow_config_untouched() { let dir = TempDir::new().unwrap(); let toml_path = dir.path().join("config.toml"); write_file(&toml_path, "enableUpdateWithMSStoreFlow = true\n"); - let mut settings = Settings::default(); - settings.enable_update_with_ms_store_flow = true; - - persist(&toml_path, &settings).unwrap(); + persist(&toml_path, &Settings::default()).unwrap(); let updated = std::fs::read_to_string(&toml_path).unwrap(); assert!(updated.contains("enableUpdateWithMSStoreFlow = true")); diff --git a/src-tauri/src/git/cli.rs b/src-tauri/src/git/cli.rs index 57cee3d..744e9d0 100644 --- a/src-tauri/src/git/cli.rs +++ b/src-tauri/src/git/cli.rs @@ -13,20 +13,22 @@ use super::handler::GitOperationHandler; use super::types::{ AddRemoteRequest, BranchInfo, BranchRequest, CherryPickRequest, CherryPickResult, CloneRequest, CommitDateMode, CommitDetails, CommitDetailsRequest, CommitFileItem, CommitFilesRequest, - CommitHistoryItem, CommitHistoryRequest, CommitLogScope, CommitMarkers, CommitRefDecoration, - CommitRefKind, CommitRequest, ConflictFileItem, CreateBranchRequest, CreateTagRequest, - DeleteBranchRequest, DeleteRemoteBranchRequest, DeleteRemoteTagRequest, DeleteTagRequest, - DiffHunk, DiffLine, DiffLineKind, DiffRequest, ExportPatchFileSelection, ExportPatchRequest, - ExportPatchScope, ExternalDiffRequest, FetchRequest, FileDiff, FileRequest, FileStatusItem, - GitIdentity, HunkStageRequest, IdentityRequest, IdentityScope, ImportPatchRequest, - LineEndingStyle, MergeRequest, MergeResult, NumstatRequest, NumstatResult, OperationResult, - PruneRemoteRequest, PullAnalysis, PullRecommendedAction, PullState, PullStrategy, - PullStrategyRequest, PushFailureKind, PushRejectionAnalysis, PushRequest, PushResult, - PushTagRequest, RebaseRequest, RebaseResult, RemoteInfo, RemoveRemoteRequest, - RenameBranchRequest, RenameRemoteRequest, RepoRequest, RepoStatus, ResetMode, ResetRequest, - RevertCommitRequest, SetBranchUpstreamRequest, SetIdentityRequest, SetRemoteUrlRequest, - SignatureStatus, StageFilesRequest, StashEntry, StashPushRequest, StashRequest, - SubmoduleActionRequest, SubmoduleState, SubmoduleStatus, TagInfo, UpstreamStatus, + CommitHistoryItem, CommitHistoryRequest, CommitLogScope, CommitMarkers, CommitMessageRecovery, + 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, + IdentityRequest, IdentityScope, ImportPatchRequest, LineEndingStyle, MergeRequest, MergeResult, + NumstatRequest, NumstatResult, OperationResult, PruneRemoteRequest, PullAnalysis, + PullRecommendedAction, PullState, PullStrategy, PullStrategyRequest, PushFailureKind, + PushRejectionAnalysis, PushRequest, PushResult, PushTagRequest, RebaseRequest, RebaseResult, + RemoteInfo, RemoveRemoteRequest, RenameBranchRequest, RenameRemoteRequest, RepoRequest, + RepoStatus, ResetMode, ResetRequest, RevertCommitRequest, SetBranchUpstreamRequest, + SetIdentityRequest, SetRemoteUrlRequest, SignatureStatus, SshAllowedSignerReason, + SshAllowedSignerStatus, StageFilesRequest, StashEntry, StashPushRequest, StashRequest, + SubmoduleActionRequest, SubmoduleState, SubmoduleStatus, TagInfo, UnversionedItem, + UnversionedItemKind, UpstreamStatus, }; pub struct CliGitHandler; @@ -43,6 +45,33 @@ struct ConfiguredSubmodule { const CREATE_NO_WINDOW: u32 = 0x08000000; impl CliGitHandler { + const EMPTY_TREE_HASH: &'static str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; + const SSH_ALLOWED_SIGNERS_ADDED: &'static str = "GITMUN_SSH_ALLOWED_SIGNERS_ADDED"; + const SSH_ALLOWED_SIGNERS_HOME_UNAVAILABLE: &'static str = + "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_HOME_UNAVAILABLE"; + const SSH_ALLOWED_SIGNERS_MISSING_EMAIL: &'static str = + "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_MISSING_EMAIL"; + const SSH_ALLOWED_SIGNERS_NO_TARGET: &'static str = + "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_NO_TARGET"; + const SSH_ALLOWED_SIGNERS_SIGNING_KEY_MISSING: &'static str = + "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_SIGNING_KEY_MISSING"; + const SSH_ALLOWED_SIGNERS_SIGNING_KEY_UNRESOLVED: &'static str = + "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_SIGNING_KEY_UNRESOLVED"; + const PATCH_EXPORT_COMMIT_HASH_EMPTY: &'static str = + "GITMUN_ERROR_PATCH_EXPORT_COMMIT_HASH_EMPTY"; + const PATCH_EXPORT_COMMIT_HASH_INVALID: &'static str = + "GITMUN_ERROR_PATCH_EXPORT_COMMIT_HASH_INVALID"; + const PATCH_EXPORT_COMMIT_HASH_INVALID_CHARACTER: &'static str = + "GITMUN_ERROR_PATCH_EXPORT_COMMIT_HASH_INVALID_CHARACTER"; + const PATCH_EXPORT_NO_CHANGES: &'static str = "GITMUN_ERROR_PATCH_EXPORT_NO_CHANGES"; + const PATCH_EXPORT_NO_COMMITS_SELECTED: &'static str = + "GITMUN_ERROR_PATCH_EXPORT_NO_COMMITS_SELECTED"; + const PATCH_EXPORT_WRITTEN: &'static str = "GITMUN_PATCH_EXPORT_WRITTEN"; + const PATCH_IMPORT_APPLIED: &'static str = "GITMUN_PATCH_IMPORT_APPLIED"; + const PATCH_IMPORT_CONFLICTS: &'static str = "GITMUN_PATCH_IMPORT_CONFLICTS"; + const PATCH_IMPORT_THREE_WAY_BLOCKED: &'static str = + "GITMUN_ERROR_PATCH_IMPORT_THREE_WAY_BLOCKED"; + fn preferred_mime_from_path(file_path: &str) -> Option { let mut first: Option = None; for mime in mime_guess::from_path(file_path) { @@ -109,6 +138,178 @@ impl CliGitHandler { Ok(path) } + fn identity_scope_flag(scope: &IdentityScope) -> &'static str { + match scope { + IdentityScope::Local => "--local", + IdentityScope::Global => "--global", + } + } + + fn scoped_config_get( + repo_path: &Path, + scope: &IdentityScope, + key: &str, + ) -> GitResult> { + let output = Self::run_git_allow_exit_codes( + &["config", Self::identity_scope_flag(scope), "--get", key], + Some(repo_path), + &[1], + )?; + let trimmed = output.trim(); + Ok((!trimmed.is_empty()).then(|| trimmed.to_string())) + } + + fn expand_user_path(path: &str) -> PathBuf { + if path == "~" || path.starts_with("~/") || path.starts_with("~\\") { + if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) + { + return PathBuf::from(home).join( + path.trim_start_matches('~') + .trim_start_matches(|ch| ch == '/' || ch == '\\'), + ); + } + } + PathBuf::from(path) + } + + fn default_global_allowed_signers_path() -> GitResult { + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .ok_or_else(|| { + GitError::InvalidInput( + Self::SSH_ALLOWED_SIGNERS_HOME_UNAVAILABLE.to_string(), + ) + })?; + Ok(PathBuf::from(home) + .join(".config") + .join("git") + .join("gitmun_allowed_signers")) + } + + fn default_allowed_signers_path(repo_path: &Path, scope: &IdentityScope) -> GitResult { + match scope { + IdentityScope::Local => { + let output = Self::run_git( + &["rev-parse", "--git-path", "gitmun_allowed_signers"], + Some(repo_path), + )?; + let path = PathBuf::from(output.trim()); + Ok(if path.is_absolute() { + path + } else { + repo_path.join(path) + }) + } + IdentityScope::Global => Self::default_global_allowed_signers_path(), + } + } + + fn resolve_allowed_signers_path( + repo_path: &Path, + scope: &IdentityScope, + configured: Option<&str>, + ) -> GitResult<(PathBuf, bool)> { + let Some(configured) = configured.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok((Self::default_allowed_signers_path(repo_path, scope)?, false)); + }; + let path = Self::expand_user_path(configured); + Ok(( + if path.is_absolute() { + path + } else { + repo_path.join(path) + }, + true, + )) + } + + fn public_key_identity(key: &str) -> Option<(&str, &str)> { + let mut parts = key.split_whitespace(); + let key_type = parts.next()?; + let key_body = parts.next()?; + if !(key_type.starts_with("ssh-") || key_type.starts_with("sk-ssh-")) { + return None; + } + Some((key_type, key_body)) + } + + fn normalise_ssh_public_key(value: &str) -> Option { + let trimmed = value.trim(); + let key = trimmed.strip_prefix("key::").unwrap_or(trimmed).trim(); + Self::public_key_identity(key)?; + Some(key.to_string()) + } + + fn path_with_pub_suffix(path: &Path) -> PathBuf { + let mut value = path.as_os_str().to_os_string(); + value.push(".pub"); + PathBuf::from(value) + } + + fn resolve_ssh_signing_public_key(signing_key: &str) -> GitResult { + if let Some(key) = Self::normalise_ssh_public_key(signing_key) { + return Ok(key); + } + + let path = Self::expand_user_path(signing_key.trim()); + if path.exists() { + let content = fs::read_to_string(&path)?; + if let Some(key) = content.lines().find_map(Self::normalise_ssh_public_key) { + return Ok(key); + } + } + + let pub_path = Self::path_with_pub_suffix(&path); + if pub_path.exists() { + let content = fs::read_to_string(&pub_path)?; + if let Some(key) = content.lines().find_map(Self::normalise_ssh_public_key) { + return Ok(key); + } + } + + Err(GitError::InvalidInput( + Self::SSH_ALLOWED_SIGNERS_SIGNING_KEY_UNRESOLVED.to_string(), + )) + } + + fn allowed_signers_contains_key(content: &str, public_key: &str) -> bool { + let Some(expected) = Self::public_key_identity(public_key) else { + return false; + }; + content.lines().any(|line| { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return false; + } + let mut parts = line.split_whitespace(); + let _principals = parts.next(); + matches!((parts.next(), parts.next()), (Some(kind), Some(body)) if (kind, body) == expected) + }) + } + + fn ssh_allowed_signer_status( + setup_needed: bool, + target_path: Option<&Path>, + blocking_reason: Option, + allowed_signers_configured: bool, + allowed_signers_exists: bool, + signing_key_present: bool, + signing_key_trusted: bool, + reason: SshAllowedSignerReason, + ) -> SshAllowedSignerStatus { + SshAllowedSignerStatus { + setup_needed, + target_path: target_path.map(Self::path_to_string), + blocking_reason, + allowed_signers_configured, + allowed_signers_exists, + signing_key_present, + signing_key_trusted, + resolved_public_key_fingerprint: None, + reason: Some(reason), + } + } + fn run_git(args: &[&str], current_dir: Option<&Path>) -> GitResult { Self::run_git_allow_exit_codes(args, current_dir, &[]) } @@ -512,6 +713,16 @@ impl CliGitHandler { )) } + fn clean_commit_edit_message(message: &str) -> String { + message + .lines() + .filter(|line| !line.starts_with('#')) + .collect::>() + .join("\n") + .trim() + .to_string() + } + fn validate_repo_relative_path(path: &str) -> GitResult { let trimmed = path.trim(); if trimmed.is_empty() { @@ -560,6 +771,36 @@ impl CliGitHandler { Ok(PathBuf::from(trimmed)) } + fn validate_commit_hash(repo_path: &Path, commit_hash: &str) -> GitResult { + let trimmed = commit_hash.trim(); + if trimmed.is_empty() { + return Err(GitError::InvalidInput( + Self::PATCH_EXPORT_COMMIT_HASH_EMPTY.to_string(), + )); + } + if trimmed.contains('\0') { + return Err(GitError::InvalidInput( + Self::PATCH_EXPORT_COMMIT_HASH_INVALID_CHARACTER.to_string(), + )); + } + + let commit_ref = format!("{trimmed}^{{commit}}"); + let output = Self::run_git_allow_exit_codes( + &["rev-parse", "--verify", "--quiet", &commit_ref], + Some(repo_path), + &[1], + )?; + let resolved = output.trim(); + if resolved.is_empty() { + return Err(GitError::InvalidInput(format!( + "{}::{trimmed}", + Self::PATCH_EXPORT_COMMIT_HASH_INVALID + ))); + } + + Ok(resolved.to_string()) + } + fn git_diff_for_paths(repo_path: &Path, staged: bool, paths: &[String]) -> GitResult { if paths.is_empty() { return Ok(String::new()); @@ -569,6 +810,7 @@ impl CliGitHandler { if staged { args.push("--cached"); } + args.push("--full-index"); args.push("--binary"); args.push("--"); args.extend(paths.iter().map(String::as_str)); @@ -580,6 +822,7 @@ impl CliGitHandler { if staged { args.push("--cached"); } + args.push("--full-index"); args.push("--binary"); args.push("--"); Self::run_git_allow_exit_codes(&args, Some(repo_path), &[1]) @@ -588,7 +831,15 @@ impl CliGitHandler { fn git_untracked_patch(repo_path: &Path, path: &str) -> GitResult { let null_device = if cfg!(windows) { "NUL" } else { "/dev/null" }; let output = Self::run_git_allow_exit_codes( - &["diff", "--no-index", "--binary", "--", null_device, path], + &[ + "diff", + "--no-index", + "--full-index", + "--binary", + "--", + null_device, + path, + ], Some(repo_path), &[1], )?; @@ -646,6 +897,51 @@ impl CliGitHandler { target.push('\n'); } + fn git_plain_patch_for_commit(repo_path: &Path, commit_hash: &str) -> GitResult { + let parents_output = Self::run_git( + &["rev-list", "--parents", "-n", "1", commit_hash], + Some(repo_path), + )?; + let mut parts = parents_output.split_whitespace(); + let resolved_hash = parts.next().unwrap_or(commit_hash); + let base_hash = parts.next().unwrap_or(Self::EMPTY_TREE_HASH); + + Self::run_git_allow_exit_codes( + &[ + "diff", + "--full-index", + "--binary", + base_hash, + resolved_hash, + "--", + ], + Some(repo_path), + &[1], + ) + } + + fn build_commit_patch( + request: &ExportCommitPatchRequest, + repo_path: &Path, + ) -> GitResult { + if request.commit_hashes.is_empty() { + return Err(GitError::InvalidInput( + Self::PATCH_EXPORT_NO_COMMITS_SELECTED.to_string(), + )); + } + + let mut output = String::new(); + for commit_hash in request.commit_hashes.iter().rev() { + let resolved_hash = Self::validate_commit_hash(repo_path, commit_hash)?; + Self::append_patch( + &mut output, + &Self::git_plain_patch_for_commit(repo_path, &resolved_hash)?, + ); + } + + Ok(output) + } + fn selected_paths(files: &[ExportPatchFileSelection], staged: bool) -> GitResult> { let mut paths = Vec::new(); let mut seen = HashSet::new(); @@ -714,6 +1010,81 @@ impl CliGitHandler { Ok(output) } + fn patch_three_way_blocked_message() -> String { + Self::PATCH_IMPORT_THREE_WAY_BLOCKED.to_string() + } + + fn status_has_tracked_changes(output: &str) -> bool { + output + .lines() + .map(str::trim) + .any(|line| !line.is_empty() && !line.starts_with("?? ")) + } + + fn status_has_unmerged_files(output: &str) -> bool { + output.lines().any(|line| { + let Some(status) = line.get(0..2) else { + return false; + }; + matches!(status, "DD" | "AU" | "UD" | "UA" | "DU" | "AA" | "UU") + }) + } + + fn ensure_clean_for_three_way_patch(repo_path: &Path) -> GitResult<()> { + let output = Self::run_git( + &["status", "--porcelain=v1", "--untracked-files=no"], + Some(repo_path), + )?; + if Self::status_has_tracked_changes(&output) { + return Err(GitError::InvalidInput( + Self::patch_three_way_blocked_message(), + )); + } + Ok(()) + } + + fn apply_patch_three_way(repo_path: &Path, patch_path: &str) -> GitResult { + Self::ensure_clean_for_three_way_patch(repo_path)?; + + match Self::run_git( + &["apply", "--3way", "--binary", patch_path], + Some(repo_path), + ) { + Ok(output) => Ok(Self::operation_result( + Self::PATCH_IMPORT_APPLIED.to_string(), + output, + repo_path, + )), + Err(GitError::CommandFailed { + command, + stderr, + exit_code, + }) => { + let status_output = Self::run_git(&["status", "--porcelain=v1"], Some(repo_path))?; + if Self::status_has_unmerged_files(&status_output) { + return Ok(Self::operation_result( + Self::PATCH_IMPORT_CONFLICTS.to_string(), + stderr, + repo_path, + )); + } + + if stderr.contains("does not match index") { + return Err(GitError::InvalidInput( + Self::patch_three_way_blocked_message(), + )); + } + + Err(GitError::CommandFailed { + command, + stderr, + exit_code, + }) + } + Err(error) => Err(error), + } + } + fn submodule_index_commit(repo_path: &Path, path: &str) -> Option { let output = Self::run_git_allow_exit_codes( &["ls-files", "--stage", "--", path], @@ -884,6 +1255,24 @@ impl CliGitHandler { status .unversioned_files .retain(|path| !Self::is_submodule_status_path(path, &submodule_paths)); + status + .unversioned_items + .retain(|item| !Self::is_submodule_status_path(&item.path, &submodule_paths)); + } + + pub(crate) fn refresh_unversioned_items(status: &mut RepoStatus, repo_path: &Path) { + status.unversioned_items = status + .unversioned_files + .iter() + .map(|path| UnversionedItem { + path: path.clone(), + kind: if repo_path.join(path).is_dir() { + UnversionedItemKind::Directory + } else { + UnversionedItemKind::File + }, + }) + .collect(); } fn current_branch_name(repo_path: &Path) -> Option { @@ -2128,6 +2517,44 @@ impl GitOperationHandler for CliGitHandler { }) } + fn get_commit_message_recovery( + &self, + request: &RepoRequest, + ) -> GitResult> { + let repo_path = Self::normalise_repo_path(&request.repo_path)?; + let path = Self::run_git( + &["rev-parse", "--git-path", "COMMIT_EDITMSG"], + Some(&repo_path), + )?; + let path = PathBuf::from(path.trim()); + let path = if path.is_absolute() { + path + } else { + repo_path.join(path) + }; + if !path.exists() { + return Ok(None); + } + + let content = fs::read_to_string(&path)?; + let message = Self::clean_commit_edit_message(&content); + if message.is_empty() { + return Ok(None); + } + + let updated_at = fs::metadata(&path) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0); + + Ok(Some(CommitMessageRecovery { + message, + updated_at, + })) + } + fn stage_files(&self, request: &StageFilesRequest) -> GitResult { let repo_path = Self::normalise_repo_path(&request.repo_path)?; let files: Vec<&str> = request @@ -2143,9 +2570,16 @@ impl GitOperationHandler for CliGitHandler { )); } - let mut args = vec!["add", "--"]; - args.extend(files.iter().copied()); - Self::run_git(&args, Some(&repo_path))?; + let mut pathspecs = Vec::new(); + for file in &files { + pathspecs.extend_from_slice(file.as_bytes()); + pathspecs.push(0); + } + Self::run_git_with_stdin( + &["add", "--pathspec-from-file=-", "--pathspec-file-nul"], + &repo_path, + &pathspecs, + )?; Ok(OperationResult { message: format!("Staged {} file(s) in {}", files.len(), repo_path.display()), @@ -2301,11 +2735,15 @@ impl GitOperationHandler for CliGitHandler { } fn import_patch_file(&self, request: &ImportPatchRequest) -> GitResult { - self.check_patch_file(request)?; - let repo_path = Self::normalise_repo_path(&request.repo_path)?; let patch_path = Self::validate_patch_path(&request.patch_path)?; let patch_path_string = patch_path.to_string_lossy().to_string(); + + if request.three_way { + return Self::apply_patch_three_way(&repo_path, &patch_path_string); + } + + self.check_patch_file(request)?; let output = Self::run_git(&["apply", "--binary", &patch_path_string], Some(&repo_path))?; Ok(Self::operation_result( @@ -2322,14 +2760,39 @@ impl GitOperationHandler for CliGitHandler { if output.trim().is_empty() { return Err(GitError::InvalidInput( - "No changes available for patch export".to_string(), + Self::PATCH_EXPORT_NO_CHANGES.to_string(), + )); + } + + fs::write(&patch_path, output.as_bytes())?; + + Ok(OperationResult { + message: Self::PATCH_EXPORT_WRITTEN.to_string(), + output: None, + repo_path: Some(Self::path_to_string(&repo_path)), + backend_used: "git-cli".to_string(), + interpreted_error: None, + }) + } + + fn export_commit_patch_file( + &self, + request: &ExportCommitPatchRequest, + ) -> GitResult { + let repo_path = Self::normalise_repo_path(&request.repo_path)?; + let patch_path = Self::validate_patch_path(&request.patch_path)?; + let output = Self::build_commit_patch(request, &repo_path)?; + + if output.trim().is_empty() { + return Err(GitError::InvalidInput( + Self::PATCH_EXPORT_NO_CHANGES.to_string(), )); } fs::write(&patch_path, output.as_bytes())?; Ok(OperationResult { - message: format!("Exported patch file to {}", patch_path.display()), + message: Self::PATCH_EXPORT_WRITTEN.to_string(), output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), @@ -2344,6 +2807,7 @@ impl GitOperationHandler for CliGitHandler { Some(&repo_path), )?; let mut status = Self::parse_repo_status(&output); + Self::refresh_unversioned_items(&mut status, &repo_path); status.submodules = Self::collect_submodules_for_status(&repo_path); Self::remove_submodule_file_entries(&mut status); status.current_branch = Self::detect_current_branch(&repo_path); @@ -3299,10 +3763,7 @@ impl GitOperationHandler for CliGitHandler { fn set_identity(&self, request: &SetIdentityRequest) -> GitResult { let repo_path = Self::normalise_repo_path(&request.repo_path)?; - let scope_flag = match request.scope { - IdentityScope::Local => "--local", - IdentityScope::Global => "--global", - }; + let scope_flag = Self::identity_scope_flag(&request.scope); let set_or_unset = |key: &str, value: &Option| -> GitResult<()> { let Some(raw) = value.as_ref() else { @@ -3371,6 +3832,186 @@ impl GitOperationHandler for CliGitHandler { }) } + fn get_ssh_allowed_signer_status( + &self, + request: &IdentityRequest, + ) -> GitResult { + let repo_path = Self::normalise_repo_path(&request.repo_path)?; + let signing_format = Self::scoped_config_get(&repo_path, &request.scope, "gpg.format")?; + if !signing_format + .as_deref() + .map(|value| value.eq_ignore_ascii_case("ssh")) + .unwrap_or(false) + { + return Ok(Self::ssh_allowed_signer_status( + false, + None, + None, + false, + false, + false, + false, + SshAllowedSignerReason::NotSsh, + )); + } + + let Some(signing_key) = + Self::scoped_config_get(&repo_path, &request.scope, "user.signingkey")? + else { + return Ok(Self::ssh_allowed_signer_status( + false, + None, + None, + false, + false, + false, + false, + SshAllowedSignerReason::MissingSigningKey, + )); + }; + + if Self::scoped_config_get(&repo_path, &request.scope, "user.email")?.is_none() { + return Ok(Self::ssh_allowed_signer_status( + false, + None, + Some(Self::SSH_ALLOWED_SIGNERS_MISSING_EMAIL.to_string()), + false, + false, + true, + false, + SshAllowedSignerReason::MissingEmail, + )); + } + + let public_key = match Self::resolve_ssh_signing_public_key(&signing_key) { + Ok(public_key) => public_key, + Err(_) => { + return Ok(Self::ssh_allowed_signer_status( + false, + None, + Some(Self::SSH_ALLOWED_SIGNERS_SIGNING_KEY_UNRESOLVED.to_string()), + false, + false, + true, + false, + SshAllowedSignerReason::UnresolvedSigningKey, + )); + } + }; + + let configured_signers = + Self::scoped_config_get(&repo_path, &request.scope, "gpg.ssh.allowedSignersFile")?; + let (target_path, configured) = Self::resolve_allowed_signers_path( + &repo_path, + &request.scope, + configured_signers.as_deref(), + )?; + let exists = target_path.exists(); + if exists { + let content = fs::read_to_string(&target_path)?; + if Self::allowed_signers_contains_key(&content, &public_key) { + return Ok(Self::ssh_allowed_signer_status( + false, + Some(&target_path), + None, + configured, + true, + true, + true, + SshAllowedSignerReason::Trusted, + )); + } + } + + let reason = if configured && !exists { + SshAllowedSignerReason::MissingAllowedSignersFile + } else { + SshAllowedSignerReason::UntrustedSigningKey + }; + Ok(Self::ssh_allowed_signer_status( + true, + Some(&target_path), + None, + configured, + exists, + true, + false, + reason, + )) + } + + fn add_ssh_signing_key_to_allowed_signers( + &self, + request: &IdentityRequest, + ) -> GitResult { + let repo_path = Self::normalise_repo_path(&request.repo_path)?; + let status = self.get_ssh_allowed_signer_status(request)?; + if let Some(reason) = status.blocking_reason { + return Err(GitError::InvalidInput(reason)); + } + let Some(target_path) = status.target_path else { + return Err(GitError::InvalidInput( + Self::SSH_ALLOWED_SIGNERS_NO_TARGET.to_string(), + )); + }; + + let signing_key = + match Self::scoped_config_get(&repo_path, &request.scope, "user.signingkey")? { + Some(value) => value, + None => { + return Err(GitError::InvalidInput( + Self::SSH_ALLOWED_SIGNERS_SIGNING_KEY_MISSING.to_string(), + )); + } + }; + let email = Self::scoped_config_get(&repo_path, &request.scope, "user.email")?.ok_or_else( + || { + GitError::InvalidInput( + Self::SSH_ALLOWED_SIGNERS_MISSING_EMAIL.to_string(), + ) + }, + )?; + let public_key = Self::resolve_ssh_signing_public_key(&signing_key)?; + let target_path = PathBuf::from(target_path); + + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent)?; + } + + let existing = fs::read_to_string(&target_path).unwrap_or_default(); + if !Self::allowed_signers_contains_key(&existing, &public_key) { + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&target_path)?; + if !existing.is_empty() && !existing.ends_with('\n') { + file.write_all(b"\n")?; + } + file.write_all(format!("{email} {public_key}\n").as_bytes())?; + } + + if !status.allowed_signers_configured { + let target_path_value = target_path.to_string_lossy().into_owned(); + Self::run_git( + &[ + "config", + Self::identity_scope_flag(&request.scope), + "gpg.ssh.allowedSignersFile", + target_path_value.as_str(), + ], + Some(&repo_path), + )?; + } + + Ok(OperationResult { + message: Self::SSH_ALLOWED_SIGNERS_ADDED.to_string(), + output: None, + repo_path: Some(Self::path_to_string(&repo_path)), + backend_used: "git-cli".to_string(), + interpreted_error: None, + }) + } + fn get_tags(&self, request: &RepoRequest) -> GitResult> { let repo_path = Self::normalise_repo_path(&request.repo_path)?; let output = match Self::run_git( @@ -4899,6 +5540,13 @@ impl CliGitHandler { RepoStatus { changed_files, staged_files, + unversioned_items: unversioned_files + .iter() + .map(|path| UnversionedItem { + path: path.clone(), + kind: UnversionedItemKind::File, + }) + .collect(), unversioned_files, submodules: vec![], current_branch: None, diff --git a/src-tauri/src/git/gix_handler.rs b/src-tauri/src/git/gix_handler.rs index 10ce80e..e64f0fc 100644 --- a/src-tauri/src/git/gix_handler.rs +++ b/src-tauri/src/git/gix_handler.rs @@ -9,16 +9,17 @@ use super::handler::GitOperationHandler; use super::types::{ AddRemoteRequest, BranchInfo, BranchRequest, CherryPickRequest, CherryPickResult, CloneRequest, CommitDateMode, CommitDetails, CommitDetailsRequest, CommitFileItem, CommitFilesRequest, - CommitHistoryItem, CommitHistoryRequest, CommitLogScope, CommitMarkers, CommitRefDecoration, - CommitRefKind, CommitRequest, ConflictFileItem, CreateBranchRequest, CreateTagRequest, - DeleteBranchRequest, DeleteRemoteBranchRequest, DeleteRemoteTagRequest, DeleteTagRequest, - DiffRequest, ExportPatchRequest, ExternalDiffRequest, FetchRequest, FileDiff, FileRequest, - FileStatusItem, 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, SignatureStatus, StageFilesRequest, StashEntry, + CommitHistoryItem, CommitHistoryRequest, CommitLogScope, CommitMarkers, CommitMessageRecovery, + CommitRefDecoration, CommitRefKind, CommitRequest, ConflictFileItem, CreateBranchRequest, + CreateTagRequest, DeleteBranchRequest, DeleteRemoteBranchRequest, DeleteRemoteTagRequest, + DeleteTagRequest, DiffRequest, ExportCommitPatchRequest, ExportPatchRequest, + ExternalDiffRequest, FetchRequest, FileDiff, FileRequest, FileStatusItem, 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, SignatureStatus, SshAllowedSignerStatus, StageFilesRequest, StashEntry, StashPushRequest, StashRequest, SubmoduleActionRequest, TagInfo, UpstreamStatus, }; @@ -61,6 +62,35 @@ impl GixGitHandler { String::from_utf8_lossy(value.as_ref()).to_string() } + fn collapse_unversioned_path(repo_path: &Path, path: &str, tracked_paths: &[String]) -> String { + let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect(); + if parts.len() <= 1 { + return if repo_path.join(path).is_dir() && !path.ends_with('/') { + format!("{path}/") + } else { + path.to_string() + }; + } + + for index in 0..parts.len() - 1 { + let candidate = parts[..=index].join("/"); + let has_tracked_descendant = tracked_paths.iter().any(|tracked_path| { + tracked_path == &candidate + || tracked_path + .strip_prefix(&candidate) + .map(|rest| rest.starts_with('/')) + .unwrap_or(false) + }); + if repo_path.join(&candidate).is_dir() + && !has_tracked_descendant + { + return format!("{candidate}/"); + } + } + + path.to_string() + } + fn mailmap_identity( mailmap: &gix::mailmap::Snapshot, signature: gix::actor::SignatureRef<'_>, @@ -794,6 +824,17 @@ impl GixGitHandler { let mut changed_by_path: HashMap = HashMap::new(); let mut staged_by_path: HashMap = HashMap::new(); let mut unversioned_paths: HashSet = HashSet::new(); + let repo_path = repo.workdir().unwrap_or(repo.path()); + let tracked_paths: Vec = repo + .index() + .map(|index| { + index + .entries() + .iter() + .map(|entry| Self::bstr_to_string(entry.path(&index))) + .collect() + }) + .unwrap_or_default(); let mut status_iter = repo .status(gix::progress::Discard) @@ -810,8 +851,12 @@ impl GixGitHandler { &worktree_item { if matches!(entry.status, gix::dir::entry::Status::Untracked) { - unversioned_paths - .insert(Self::bstr_to_string(entry.rela_path.as_ref())); + let path = Self::bstr_to_string(entry.rela_path.as_ref()); + unversioned_paths.insert(Self::collapse_unversioned_path( + repo_path, + &path, + &tracked_paths, + )); continue; } } @@ -851,7 +896,6 @@ impl GixGitHandler { .collect(); staged_files.sort_by(|left, right| left.path.cmp(&right.path)); - let repo_path = repo.workdir().unwrap_or(repo.path()); let unstaged_stats = Self::collect_numstat(repo_path, false); let staged_stats = Self::collect_numstat(repo_path, true); @@ -919,6 +963,7 @@ impl GixGitHandler { changed_files, staged_files, unversioned_files, + unversioned_items: vec![], submodules: CliGitHandler::collect_submodules_for_status(repo_path), current_branch: Self::current_branch(repo), detached_head: matches!(repo.head_name(), Ok(None)), @@ -934,6 +979,7 @@ impl GixGitHandler { revert_in_progress, revert_head, }; + CliGitHandler::refresh_unversioned_items(&mut status, repo_path); CliGitHandler::remove_submodule_file_entries(&mut status); Ok(status) } @@ -1028,6 +1074,14 @@ impl GitOperationHandler for GixGitHandler { .map(Self::with_cli_fallback_backend) } + fn get_commit_message_recovery( + &self, + request: &RepoRequest, + ) -> GitResult> { + self.validate_repo_with_gix(&request.repo_path)?; + self.cli_fallback.get_commit_message_recovery(request) + } + fn stage_files(&self, request: &StageFilesRequest) -> GitResult { self.validate_repo_with_gix(&request.repo_path)?; self.cli_fallback @@ -1078,6 +1132,16 @@ impl GitOperationHandler for GixGitHandler { .map(Self::with_cli_fallback_backend) } + fn export_commit_patch_file( + &self, + request: &ExportCommitPatchRequest, + ) -> GitResult { + self.validate_repo_with_gix(&request.repo_path)?; + self.cli_fallback + .export_commit_patch_file(request) + .map(Self::with_cli_fallback_backend) + } + fn get_repo_status(&self, request: &RepoRequest) -> GitResult { let repo_path = Path::new(request.repo_path.trim()); let repo = gix::discover(repo_path).map_err(|error| Self::gix_error(None, error)); @@ -1323,6 +1387,24 @@ impl GitOperationHandler for GixGitHandler { .map(Self::with_cli_fallback_backend) } + fn get_ssh_allowed_signer_status( + &self, + request: &IdentityRequest, + ) -> GitResult { + self.validate_repo_with_gix(&request.repo_path)?; + self.cli_fallback.get_ssh_allowed_signer_status(request) + } + + fn add_ssh_signing_key_to_allowed_signers( + &self, + request: &IdentityRequest, + ) -> GitResult { + self.validate_repo_with_gix(&request.repo_path)?; + self.cli_fallback + .add_ssh_signing_key_to_allowed_signers(request) + .map(Self::with_cli_fallback_backend) + } + fn get_tags(&self, request: &RepoRequest) -> GitResult> { let repo_path = Path::new(request.repo_path.trim()); let repo = gix::discover(repo_path).map_err(|e| Self::gix_error(None, e)); diff --git a/src-tauri/src/git/handler.rs b/src-tauri/src/git/handler.rs index ed9bd5d..fceec63 100644 --- a/src-tauri/src/git/handler.rs +++ b/src-tauri/src/git/handler.rs @@ -8,16 +8,17 @@ use super::types::{ AddRemoteRequest, BackendMode, BranchInfo, BranchRequest, CherryPickRequest, CherryPickResult, CloneRequest, CommitDateMode, CommitDetails, CommitDetailsRequest, CommitFileItem, CommitFilesRequest, CommitHistoryItem, CommitHistoryRequest, CommitMarkers, - CommitPrimaryAction, CommitRequest, CreateBranchRequest, CreateTagRequest, DeleteBranchRequest, - DeleteRemoteBranchRequest, DeleteRemoteTagRequest, DeleteTagRequest, DiffRequest, - ExportPatchRequest, ExternalDiffRequest, FetchRequest, FileDiff, FileRequest, GitIdentity, - HunkStageRequest, IdentityRequest, ImportPatchRequest, MergeRequest, MergeResult, - NumstatRequest, NumstatResult, OperationResult, PruneRemoteRequest, PullAnalysis, + 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, StageFilesRequest, StashEntry, StashPushRequest, StashRequest, - SubmoduleActionRequest, TagInfo, ThemeMode, + SetRemoteUrlRequest, Settings, SshAllowedSignerStatus, StageFilesRequest, StashEntry, + StashPushRequest, StashRequest, SubmoduleActionRequest, TagInfo, ThemeMode, }; pub trait GitOperationHandler: Send + Sync { @@ -30,6 +31,10 @@ pub trait GitOperationHandler: Send + Sync { fn pull_with_strategy(&self, request: &PullStrategyRequest) -> GitResult; fn push_changes(&self, request: &PushRequest) -> GitResult; fn commit_changes(&self, request: &CommitRequest) -> GitResult; + fn get_commit_message_recovery( + &self, + request: &RepoRequest, + ) -> GitResult>; fn stage_files(&self, request: &StageFilesRequest) -> GitResult; fn get_configured_diff_tool(&self, request: &RepoRequest) -> GitResult>; fn open_external_diff(&self, request: &ExternalDiffRequest) -> GitResult; @@ -37,6 +42,10 @@ pub trait GitOperationHandler: Send + Sync { fn check_patch_file(&self, request: &ImportPatchRequest) -> GitResult; fn import_patch_file(&self, request: &ImportPatchRequest) -> GitResult; fn export_patch_file(&self, request: &ExportPatchRequest) -> GitResult; + fn export_commit_patch_file( + &self, + request: &ExportCommitPatchRequest, + ) -> GitResult; fn get_repo_status(&self, request: &RepoRequest) -> GitResult; fn get_commit_history( &self, @@ -67,6 +76,14 @@ pub trait GitOperationHandler: Send + Sync { fn stash_drop(&self, request: &StashRequest) -> GitResult; fn get_identity(&self, request: &IdentityRequest) -> GitResult; fn set_identity(&self, request: &SetIdentityRequest) -> GitResult; + fn get_ssh_allowed_signer_status( + &self, + request: &IdentityRequest, + ) -> GitResult; + fn add_ssh_signing_key_to_allowed_signers( + &self, + request: &IdentityRequest, + ) -> GitResult; fn get_tags(&self, request: &RepoRequest) -> GitResult>; fn get_remotes(&self, request: &RepoRequest) -> GitResult>; fn switch_branch(&self, request: &BranchRequest) -> GitResult; @@ -410,6 +427,14 @@ impl GitService { self.active_read_handler().validate_repo_path(repo_path) } + pub fn get_commit_message_recovery( + &self, + request: RepoRequest, + ) -> GitResult> { + self.active_read_handler() + .get_commit_message_recovery(&request) + } + forward_write_methods! { fn analyze_pull(request: RepoRequest) -> GitResult; fn pull_changes(request: RepoRequest) -> GitResult; @@ -435,6 +460,7 @@ impl GitService { fn stash_pop(request: StashRequest) -> GitResult; fn stash_drop(request: StashRequest) -> GitResult; fn set_identity(request: SetIdentityRequest) -> GitResult; + fn add_ssh_signing_key_to_allowed_signers(request: IdentityRequest) -> GitResult; fn switch_branch(request: BranchRequest) -> GitResult; fn set_branch_upstream(request: SetBranchUpstreamRequest) -> GitResult; fn delete_branch(request: DeleteBranchRequest) -> GitResult; @@ -467,6 +493,7 @@ impl GitService { fn check_patch_file(request: ImportPatchRequest) -> GitResult; fn import_patch_file(request: ImportPatchRequest) -> GitResult; fn export_patch_file(request: ExportPatchRequest) -> GitResult; + fn export_commit_patch_file(request: ExportCommitPatchRequest) -> GitResult; } forward_read_methods! { @@ -481,6 +508,7 @@ impl GitService { fn get_branches(request: RepoRequest) -> GitResult>; fn stash_list(request: RepoRequest) -> GitResult>; fn get_identity(request: IdentityRequest) -> GitResult; + fn get_ssh_allowed_signer_status(request: IdentityRequest) -> GitResult; fn get_tags(request: RepoRequest) -> GitResult>; fn get_remotes(request: RepoRequest) -> GitResult>; } diff --git a/src-tauri/src/git/types.rs b/src-tauri/src/git/types.rs index 732868f..15a2fa8 100644 --- a/src-tauri/src/git/types.rs +++ b/src-tauri/src/git/types.rs @@ -277,8 +277,6 @@ pub struct Settings { pub auto_install_updates: bool, #[serde(default = "Settings::default_update_endpoint")] pub update_endpoint: String, - #[serde(default, rename = "enableUpdateWithMSStoreFlow")] - pub enable_update_with_ms_store_flow: bool, #[serde(default)] pub linux_graphics_mode: LinuxGraphicsMode, #[serde(default)] @@ -298,6 +296,8 @@ pub struct Settings { pub struct ImportPatchRequest { pub repo_path: String, pub patch_path: String, + #[serde(default)] + pub three_way: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -317,6 +317,15 @@ pub struct ExportPatchRequest { pub files: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportCommitPatchRequest { + pub repo_path: String, + pub patch_path: String, + #[serde(default)] + pub commit_hashes: Vec, +} + impl Default for Settings { fn default() -> Self { Self { @@ -342,7 +351,6 @@ impl Default for Settings { auto_check_for_updates_on_launch: true, auto_install_updates: false, update_endpoint: Self::default_update_endpoint(), - enable_update_with_ms_store_flow: false, linux_graphics_mode: LinuxGraphicsMode::Auto, linux_terminal_emulator: LinuxTerminalEmulator::Auto, linux_terminal_custom_command: String::new(), @@ -486,6 +494,13 @@ pub struct CommitRequest { pub amend: Option, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CommitMessageRecovery { + pub message: String, + pub updated_at: u64, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct OperationResult { @@ -554,12 +569,27 @@ pub struct SubmoduleStatus { pub state: SubmoduleState, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum UnversionedItemKind { + File, + Directory, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UnversionedItem { + pub path: String, + pub kind: UnversionedItemKind, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct RepoStatus { pub changed_files: Vec, pub staged_files: Vec, pub unversioned_files: Vec, + pub unversioned_items: Vec, pub submodules: Vec, pub current_branch: Option, pub detached_head: bool, @@ -810,6 +840,32 @@ pub struct GitIdentity { pub commit_signing_enabled: bool, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum SshAllowedSignerReason { + NotSsh, + MissingSigningKey, + MissingEmail, + MissingAllowedSignersFile, + UntrustedSigningKey, + Trusted, + UnresolvedSigningKey, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SshAllowedSignerStatus { + pub setup_needed: bool, + pub target_path: Option, + pub blocking_reason: Option, + pub allowed_signers_configured: bool, + pub allowed_signers_exists: bool, + pub signing_key_present: bool, + pub signing_key_trusted: bool, + pub resolved_public_key_fingerprint: Option, + pub reason: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub enum IdentityScope { #[serde(rename = "Local", alias = "local")] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5e232b0..a072775 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1515,7 +1515,7 @@ pub fn run() { commands::settings::check_for_app_update, commands::settings::download_and_install_app_update, commands::store_update::check_microsoft_store_update, - commands::store_update::request_microsoft_store_update, + commands::store_update::open_microsoft_store_update_page, commands::settings::get_global_diff_tool, commands::settings::get_global_diff_tool_path, commands::settings::get_global_default_branch, @@ -1596,6 +1596,7 @@ pub fn run() { commands::repo::check_patch_file, commands::repo::import_patch_file, commands::repo::export_patch_file, + commands::repo::export_commit_patch_file, commands::repo::get_repo_diff_tool, commands::repo::analyze_pull, commands::repo::pull_changes, @@ -1604,6 +1605,7 @@ pub fn run() { commands::repo::get_numstat, commands::repo::stage_files, commands::repo::commit_changes, + commands::repo::get_commit_message_recovery, commands::repo::get_diff, commands::repo::unstage_file, commands::repo::unstage_all, @@ -1624,6 +1626,8 @@ pub fn run() { commands::repo::stash_drop, commands::repo::get_identity, commands::repo::set_identity, + commands::repo::get_ssh_allowed_signer_status, + commands::repo::add_ssh_signing_key_to_allowed_signers, commands::repo::push_changes, commands::repo::fetch_avatar, commands::branches::get_branches, diff --git a/src-tauri/tests/git.rs b/src-tauri/tests/git.rs index 950c52b..2331208 100644 --- a/src-tauri/tests/git.rs +++ b/src-tauri/tests/git.rs @@ -9,10 +9,11 @@ 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, ExportPatchFileSelection, ExportPatchRequest, - ExportPatchScope, FileRequest, ImportPatchRequest, PushFailureKind, PushRequest, RepoRequest, - ResetMode, ResetRequest, SetBranchUpstreamRequest, StageFilesRequest, SubmoduleActionRequest, - SubmoduleState, + CreateBranchRequest, DeleteBranchRequest, ExportCommitPatchRequest, ExportPatchFileSelection, + ExportPatchRequest, ExportPatchScope, FileRequest, IdentityRequest, IdentityScope, + ImportPatchRequest, PushFailureKind, PushRequest, RepoRequest, RepoStatus, ResetMode, + ResetRequest, SetBranchUpstreamRequest, SetIdentityRequest, SshAllowedSignerReason, + StageFilesRequest, SubmoduleActionRequest, SubmoduleState, UnversionedItemKind, }; fn init_repo() -> TempDir { @@ -88,6 +89,28 @@ fn file_request(dir: &TempDir, path: &str) -> FileRequest { } } +fn identity_request(dir: &TempDir) -> IdentityRequest { + IdentityRequest { + repo_path: dir.path().to_str().unwrap().to_string(), + scope: IdentityScope::Local, + } +} + +fn set_local_identity(dir: &TempDir, email: Option<&str>, signing_key: Option<&str>, format: &str) { + handler() + .set_identity(&SetIdentityRequest { + repo_path: dir.path().to_str().unwrap().to_string(), + scope: IdentityScope::Local, + name: Some("Gitmun Test".to_string()), + email: email.map(str::to_string), + signing_key: signing_key.map(str::to_string), + signing_format: Some(format.to_string()), + ssh_key_path: None, + commit_signing_enabled: Some(true), + }) + .expect("set identity"); +} + fn handler() -> CliGitHandler { CliGitHandler } @@ -215,6 +238,19 @@ fn import_patch_request(repo: &TempDir, patch_path: &Path) -> ImportPatchRequest ImportPatchRequest { repo_path: repo.path().to_str().unwrap().to_string(), patch_path: patch_path.to_str().unwrap().to_string(), + three_way: false, + } +} + +fn import_patch_request_with_three_way( + repo: &TempDir, + patch_path: &Path, + three_way: bool, +) -> ImportPatchRequest { + ImportPatchRequest { + repo_path: repo.path().to_str().unwrap().to_string(), + patch_path: patch_path.to_str().unwrap().to_string(), + three_way, } } @@ -232,6 +268,35 @@ fn export_patch_request( } } +fn assert_patch_contains_full_index(output: &str) { + let index_line = output + .lines() + .find(|line| line.starts_with("index ")) + .expect("patch has index line"); + let ids = index_line + .trim_start_matches("index ") + .split_whitespace() + .next() + .expect("index line has object ids"); + let (old, new) = ids + .split_once("..") + .expect("index line separates object ids"); + assert_eq!(old.len(), 40); + assert_eq!(new.len(), 40); +} + +fn export_commit_patch_request( + repo: &TempDir, + patch_path: &Path, + commit_hashes: Vec, +) -> ExportCommitPatchRequest { + ExportCommitPatchRequest { + repo_path: repo.path().to_str().unwrap().to_string(), + patch_path: patch_path.to_str().unwrap().to_string(), + commit_hashes, + } +} + #[test] fn status_clean_repo() { let dir = init_repo(); @@ -254,6 +319,55 @@ fn status_detects_untracked_file() { assert!(status.unversioned_files.iter().any(|f| f == "new.txt")); } +fn assert_unversioned_item(status: &RepoStatus, path: &str, kind: UnversionedItemKind) { + assert!( + status + .unversioned_items + .iter() + .any(|item| item.path == path && item.kind == kind), + "missing {kind:?} item for {path}: {:?}", + status.unversioned_items + ); +} + +#[test] +fn status_reports_untracked_item_kinds() { + let dir = init_repo(); + fs::create_dir_all(dir.path().join("tracked")).expect("create tracked directory"); + write_file(dir.path(), "tracked/kept.txt", "kept"); + git(dir.path(), &["add", "tracked/kept.txt"]); + git(dir.path(), &["commit", "-m", "track directory"]); + + write_file(dir.path(), "new.txt", "hello"); + write_file(dir.path(), "tracked/new.txt", "new"); + fs::create_dir_all(dir.path().join("notes")).expect("create notes directory"); + write_file(dir.path(), "notes/draft.txt", "draft"); + + let cli_status = handler() + .get_repo_status(&repo_request(&dir)) + .expect("cli get_repo_status"); + assert!(cli_status.unversioned_files.iter().any(|path| path == "new.txt")); + assert!(cli_status + .unversioned_files + .iter() + .any(|path| path == "notes/")); + assert_unversioned_item(&cli_status, "new.txt", UnversionedItemKind::File); + assert_unversioned_item(&cli_status, "tracked/new.txt", UnversionedItemKind::File); + assert_unversioned_item(&cli_status, "notes/", UnversionedItemKind::Directory); + + let gix_status = gix_handler() + .get_repo_status(&repo_request(&dir)) + .expect("gix get_repo_status"); + assert!(gix_status.unversioned_files.iter().any(|path| path == "new.txt")); + assert!(gix_status + .unversioned_files + .iter() + .any(|path| path == "notes/")); + assert_unversioned_item(&gix_status, "new.txt", UnversionedItemKind::File); + assert_unversioned_item(&gix_status, "tracked/new.txt", UnversionedItemKind::File); + assert_unversioned_item(&gix_status, "notes/", UnversionedItemKind::Directory); +} + #[test] fn status_detects_staged_file() { let dir = init_repo(); @@ -336,6 +450,104 @@ fn import_patch_rejects_non_applicable_patch_without_modifying_files() { assert_eq!(read_file(dir.path(), "file.txt"), "different\n"); } +#[test] +fn import_patch_three_way_returns_conflict_result_for_drifted_full_index_patch() { + let dir = init_repo(); + write_file(dir.path(), "file.txt", "base\n"); + git(dir.path(), &["add", "file.txt"]); + git(dir.path(), &["commit", "-m", "add base file"]); + let base_hash = head_hash(dir.path()); + + write_file(dir.path(), "file.txt", "patch\n"); + git(dir.path(), &["add", "file.txt"]); + git(dir.path(), &["commit", "-m", "patch change"]); + let patch = dir.path().join("change.patch"); + let patch_content = git_stdout( + dir.path(), + &["diff", "--full-index", "--binary", &base_hash, "HEAD", "--"], + ); + fs::write(&patch, format!("{patch_content}\n")).expect("write patch"); + + git(dir.path(), &["reset", "--hard", &base_hash]); + write_file(dir.path(), "file.txt", "target\n"); + git(dir.path(), &["add", "file.txt"]); + git(dir.path(), &["commit", "-m", "target drift"]); + + let result = handler() + .import_patch_file(&import_patch_request_with_three_way(&dir, &patch, true)) + .expect("three-way import result"); + + assert_eq!(result.message, "GITMUN_PATCH_IMPORT_CONFLICTS"); + assert!( + git_stdout(dir.path(), &["status", "--porcelain"]) + .lines() + .any(|line| line.starts_with("UU file.txt")) + ); + assert!(read_file(dir.path(), "file.txt").contains("<<<<<<<")); +} + +#[test] +fn import_patch_three_way_failure_without_blob_data_leaves_files_unchanged() { + let dir = init_repo(); + write_file(dir.path(), "file.txt", "different\n"); + git(dir.path(), &["add", "file.txt"]); + git(dir.path(), &["commit", "-m", "add file"]); + + let patch = dir.path().join("change.patch"); + fs::write( + &patch, + "diff --git a/file.txt b/file.txt\n\ + index 624785c..172491a 100644\n\ + --- a/file.txt\n\ + +++ b/file.txt\n\ + @@ -1 +1 @@\n\ + -before\n\ + +after\n", + ) + .expect("write patch"); + + let result = + handler().import_patch_file(&import_patch_request_with_three_way(&dir, &patch, true)); + + assert!(result.is_err()); + assert_eq!(read_file(dir.path(), "file.txt"), "different\n"); + assert!( + git_stdout(dir.path(), &["status", "--porcelain"]) + .lines() + .all(|line| !line.starts_with("UU ")) + ); +} + +#[test] +fn import_patch_three_way_blocks_dirty_tracked_files() { + let dir = init_repo(); + write_file(dir.path(), "file.txt", "base\n"); + git(dir.path(), &["add", "file.txt"]); + git(dir.path(), &["commit", "-m", "add base file"]); + let base_hash = head_hash(dir.path()); + + write_file(dir.path(), "file.txt", "patch\n"); + git(dir.path(), &["add", "file.txt"]); + git(dir.path(), &["commit", "-m", "patch change"]); + let patch = dir.path().join("change.patch"); + let patch_content = git_stdout( + dir.path(), + &["diff", "--full-index", "--binary", &base_hash, "HEAD", "--"], + ); + fs::write(&patch, format!("{patch_content}\n")).expect("write patch"); + + git(dir.path(), &["reset", "--hard", &base_hash]); + write_file(dir.path(), "file.txt", "local\n"); + + let error = handler() + .import_patch_file(&import_patch_request_with_three_way(&dir, &patch, true)) + .expect_err("dirty files block three-way apply") + .to_string(); + + assert!(error.contains("GITMUN_ERROR_PATCH_IMPORT_THREE_WAY_BLOCKED")); + assert_eq!(read_file(dir.path(), "file.txt"), "local\n"); +} + #[test] fn export_staged_patch_contains_only_staged_changes() { let dir = init_repo(); @@ -360,6 +572,7 @@ fn export_staged_patch_contains_only_staged_changes() { assert!(output.contains("diff --git a/staged.txt b/staged.txt")); assert!(!output.contains("unstaged.txt")); + assert_patch_contains_full_index(&output); } #[test] @@ -385,6 +598,7 @@ fn export_unstaged_patch_contains_tracked_unstaged_and_untracked_files() { assert!(output.contains("diff --git a/tracked.txt b/tracked.txt")); assert!(output.contains("diff --git a/new.txt b/new.txt")); assert!(output.contains("new file mode")); + assert_patch_contains_full_index(&output); } #[test] @@ -413,6 +627,7 @@ fn export_all_concatenates_staged_unstaged_and_untracked_changes() { assert!(output.contains("diff --git a/staged.txt b/staged.txt")); assert!(output.contains("diff --git a/unstaged.txt b/unstaged.txt")); assert!(output.contains("diff --git a/new.txt b/new.txt")); + assert_patch_contains_full_index(&output); } #[test] @@ -460,6 +675,97 @@ fn export_selected_honours_staged_and_unstaged_file_selections() { assert!(output.contains("diff --git a/unstaged.txt b/unstaged.txt")); assert!(output.contains("diff --git a/new.txt b/new.txt")); assert!(!output.contains("ignored.txt")); + assert_patch_contains_full_index(&output); +} + +#[test] +fn export_commit_patch_contains_selected_commit_changes() { + let dir = init_repo(); + write_file(dir.path(), "commit.txt", "first\n"); + git(dir.path(), &["add", "commit.txt"]); + git(dir.path(), &["commit", "-m", "add commit file"]); + let hash = head_hash(dir.path()); + + let patch = dir.path().join("commit.patch"); + handler() + .export_commit_patch_file(&export_commit_patch_request(&dir, &patch, vec![hash])) + .expect("export commit patch"); + let output = fs::read_to_string(patch).expect("read patch"); + + assert!(output.contains("diff --git a/commit.txt b/commit.txt")); + assert!(output.contains("+first")); + assert_patch_contains_full_index(&output); +} + +#[test] +fn export_commit_patch_orders_multiple_commits_oldest_first() { + let dir = init_repo(); + write_file(dir.path(), "first.txt", "first\n"); + git(dir.path(), &["add", "first.txt"]); + git(dir.path(), &["commit", "-m", "first"]); + let first_hash = head_hash(dir.path()); + write_file(dir.path(), "second.txt", "second\n"); + git(dir.path(), &["add", "second.txt"]); + git(dir.path(), &["commit", "-m", "second"]); + let second_hash = head_hash(dir.path()); + + let patch = dir.path().join("commits.patch"); + handler() + .export_commit_patch_file(&export_commit_patch_request( + &dir, + &patch, + vec![second_hash, first_hash], + )) + .expect("export commit patch"); + let output = fs::read_to_string(patch).expect("read patch"); + + let first_index = output.find("diff --git a/first.txt b/first.txt").unwrap(); + let second_index = output.find("diff --git a/second.txt b/second.txt").unwrap(); + assert!(first_index < second_index); +} + +#[test] +fn export_commit_patch_supports_root_commit() { + let dir = init_unborn_repo(); + write_file(dir.path(), "root.txt", "root\n"); + git(dir.path(), &["add", "root.txt"]); + git(dir.path(), &["commit", "-m", "root"]); + let hash = head_hash(dir.path()); + + let patch = dir.path().join("root.patch"); + handler() + .export_commit_patch_file(&export_commit_patch_request(&dir, &patch, vec![hash])) + .expect("export root commit patch"); + let output = fs::read_to_string(patch).expect("read patch"); + + assert!(output.contains("diff --git a/root.txt b/root.txt")); + assert!(output.contains("new file mode")); + assert!(output.contains("+root")); +} + +#[test] +fn export_commit_patch_rejects_empty_selection() { + let dir = init_repo(); + let patch = dir.path().join("empty.patch"); + let result = + handler().export_commit_patch_file(&export_commit_patch_request(&dir, &patch, vec![])); + + assert!(result.is_err()); + assert!(!patch.exists()); +} + +#[test] +fn export_commit_patch_rejects_invalid_commit_hash() { + let dir = init_repo(); + let patch = dir.path().join("invalid.patch"); + let result = handler().export_commit_patch_file(&export_commit_patch_request( + &dir, + &patch, + vec!["not-a-commit".to_string()], + )); + + assert!(result.is_err()); + assert!(!patch.exists()); } #[test] @@ -527,6 +833,32 @@ fn stage_files_moves_file_to_staged() { assert!(status.unversioned_files.is_empty()); } +#[test] +fn stage_files_handles_large_selection_with_spaces() { + let dir = init_repo(); + fs::create_dir_all(dir.path().join("bulk files")).expect("create bulk directory"); + + let mut files = vec!["bulk files/root note.txt".to_string()]; + write_file(dir.path(), &files[0], "root"); + for index in 0..200 { + let path = format!("bulk files/file {index}.txt"); + write_file(dir.path(), &path, "content"); + files.push(path); + } + + handler() + .stage_files(&StageFilesRequest { + repo_path: dir.path().to_str().unwrap().to_string(), + files: files.clone(), + }) + .expect("stage_files"); + + let staged = git_stdout(dir.path(), &["diff", "--cached", "--name-only"]); + for path in files { + assert!(staged.lines().any(|line| line == path), "missing {path}"); + } +} + #[test] fn unstage_file_in_initial_commit_keeps_worktree_file() { let dir = init_unborn_repo(); @@ -866,6 +1198,332 @@ fn commit_preserves_description_and_trailer_like_lines() { assert_eq!(committed_message, message); } +#[test] +fn commit_message_recovery_reads_commit_editmsg() { + let dir = init_repo(); + let commit_editmsg = git_stdout(dir.path(), &["rev-parse", "--git-path", "COMMIT_EDITMSG"]); + fs::write( + dir.path().join(commit_editmsg), + "Recovered subject\n\nRecovered body\n# ignored comment\n", + ) + .expect("write COMMIT_EDITMSG"); + + let recovery = handler() + .get_commit_message_recovery(&repo_request(&dir)) + .expect("get_commit_message_recovery") + .expect("recovery message"); + + assert_eq!(recovery.message, "Recovered subject\n\nRecovered body"); + assert!(recovery.updated_at > 0); +} + +#[test] +fn commit_message_recovery_ignores_missing_or_comment_only_file() { + let dir = init_repo(); + let commit_editmsg = git_stdout(dir.path(), &["rev-parse", "--git-path", "COMMIT_EDITMSG"]); + let commit_editmsg_path = dir.path().join(commit_editmsg); + fs::remove_file(&commit_editmsg_path).expect("remove COMMIT_EDITMSG"); + + assert!( + handler() + .get_commit_message_recovery(&repo_request(&dir)) + .expect("missing COMMIT_EDITMSG") + .is_none() + ); + + fs::write(commit_editmsg_path, "# comment only\n\n# still comment\n").expect("write COMMIT_EDITMSG"); + + assert!( + handler() + .get_commit_message_recovery(&repo_request(&dir)) + .expect("comment-only COMMIT_EDITMSG") + .is_none() + ); +} + +#[test] +fn ssh_allowed_signer_status_ignores_non_ssh_signing() { + let dir = init_repo(); + set_local_identity(&dir, Some("test@gitmun.test"), Some("ABC123"), "openpgp"); + + let status = handler() + .get_ssh_allowed_signer_status(&identity_request(&dir)) + .expect("status"); + + assert!(!status.setup_needed); + assert_eq!(status.blocking_reason, None); + assert_eq!(status.reason, Some(SshAllowedSignerReason::NotSsh)); +} + +#[test] +fn ssh_allowed_signer_status_requires_signing_key() { + let dir = init_repo(); + set_local_identity(&dir, Some("test@gitmun.test"), None, "ssh"); + + let status = handler() + .get_ssh_allowed_signer_status(&identity_request(&dir)) + .expect("status"); + + assert!(!status.setup_needed); + assert_eq!(status.blocking_reason, None); + assert_eq!( + status.reason, + Some(SshAllowedSignerReason::MissingSigningKey) + ); + assert!(!status.signing_key_present); +} + +#[test] +fn ssh_allowed_signer_adds_inline_key_to_local_default_file() { + let dir = init_repo(); + let key = "key::ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey test@gitmun.test"; + set_local_identity(&dir, Some("test@gitmun.test"), Some(key), "ssh"); + + let status = handler() + .get_ssh_allowed_signer_status(&identity_request(&dir)) + .expect("status"); + assert!(status.setup_needed); + assert!(!status.allowed_signers_configured); + assert!(!status.allowed_signers_exists); + assert_eq!( + status.reason, + Some(SshAllowedSignerReason::UntrustedSigningKey) + ); + assert!(status.signing_key_present); + assert!(!status.signing_key_trusted); + + handler() + .add_ssh_signing_key_to_allowed_signers(&identity_request(&dir)) + .expect("add allowed signer"); + + let configured = git_stdout( + dir.path(), + &["config", "--local", "--get", "gpg.ssh.allowedSignersFile"], + ); + let content = fs::read_to_string(&configured).expect("read allowed signers"); + assert_eq!( + content, + "test@gitmun.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey test@gitmun.test\n" + ); + assert!(!git_stdout(dir.path(), &["status", "--porcelain"]).contains("gitmun_allowed_signers")); +} + +#[test] +fn ssh_allowed_signer_accepts_raw_public_key() { + let dir = init_repo(); + let allowed = dir.path().join("allowed_signers"); + git( + dir.path(), + &[ + "config", + "--local", + "gpg.ssh.allowedSignersFile", + allowed.to_str().unwrap(), + ], + ); + set_local_identity( + &dir, + Some("test@gitmun.test"), + Some("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIRawKey"), + "ssh", + ); + + handler() + .add_ssh_signing_key_to_allowed_signers(&identity_request(&dir)) + .expect("add allowed signer"); + + assert_eq!( + fs::read_to_string(allowed).expect("read allowed signers"), + "test@gitmun.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIRawKey\n" + ); +} + +#[test] +fn ssh_allowed_signer_reports_configured_missing_file() { + let dir = init_repo(); + let allowed = dir.path().join("missing_allowed_signers"); + git( + dir.path(), + &[ + "config", + "--local", + "gpg.ssh.allowedSignersFile", + allowed.to_str().unwrap(), + ], + ); + set_local_identity( + &dir, + Some("test@gitmun.test"), + Some("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMissingFile"), + "ssh", + ); + + let status = handler() + .get_ssh_allowed_signer_status(&identity_request(&dir)) + .expect("status"); + + assert!(status.setup_needed); + assert!(status.allowed_signers_configured); + assert!(!status.allowed_signers_exists); + assert_eq!( + status.reason, + Some(SshAllowedSignerReason::MissingAllowedSignersFile) + ); +} + +#[test] +fn ssh_allowed_signer_accepts_public_key_file() { + let dir = init_repo(); + let public_key = dir.path().join("id_ed25519.pub"); + fs::write( + &public_key, + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFileKey test@gitmun.test\n", + ) + .expect("write public key"); + set_local_identity( + &dir, + Some("test@gitmun.test"), + Some(public_key.to_str().unwrap()), + "ssh", + ); + + handler() + .add_ssh_signing_key_to_allowed_signers(&identity_request(&dir)) + .expect("add allowed signer"); + + let configured = git_stdout( + dir.path(), + &["config", "--local", "--get", "gpg.ssh.allowedSignersFile"], + ); + assert!( + fs::read_to_string(configured) + .expect("read allowed signers") + .contains("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFileKey") + ); +} + +#[test] +fn ssh_allowed_signer_accepts_private_key_path_with_public_pair() { + let dir = init_repo(); + let private_key = dir.path().join("id_ed25519"); + fs::write(&private_key, "private key placeholder").expect("write private key"); + fs::write( + dir.path().join("id_ed25519.pub"), + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPairedKey test@gitmun.test\n", + ) + .expect("write public key"); + set_local_identity( + &dir, + Some("test@gitmun.test"), + Some(private_key.to_str().unwrap()), + "ssh", + ); + + handler() + .add_ssh_signing_key_to_allowed_signers(&identity_request(&dir)) + .expect("add allowed signer"); + + let configured = git_stdout( + dir.path(), + &["config", "--local", "--get", "gpg.ssh.allowedSignersFile"], + ); + assert!( + fs::read_to_string(configured) + .expect("read allowed signers") + .contains("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPairedKey") + ); +} + +#[test] +fn ssh_allowed_signer_does_not_duplicate_existing_key() { + let dir = init_repo(); + let allowed = dir.path().join("allowed_signers"); + fs::write( + &allowed, + "other@example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDupeKey other\n", + ) + .expect("write allowed signers"); + git( + dir.path(), + &[ + "config", + "--local", + "gpg.ssh.allowedSignersFile", + allowed.to_str().unwrap(), + ], + ); + set_local_identity( + &dir, + Some("test@gitmun.test"), + Some("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDupeKey test"), + "ssh", + ); + + let status = handler() + .get_ssh_allowed_signer_status(&identity_request(&dir)) + .expect("status"); + assert!(!status.setup_needed); + assert_eq!(status.reason, Some(SshAllowedSignerReason::Trusted)); + assert!(status.signing_key_trusted); + + handler() + .add_ssh_signing_key_to_allowed_signers(&identity_request(&dir)) + .expect("add allowed signer"); + let content = fs::read_to_string(allowed).expect("read allowed signers"); + assert_eq!(content.lines().count(), 1); +} + +#[test] +fn ssh_allowed_signer_requires_email() { + let dir = init_repo(); + git(dir.path(), &["config", "--local", "--unset", "user.email"]); + set_local_identity( + &dir, + None, + Some("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINoEmail"), + "ssh", + ); + + let status = handler() + .get_ssh_allowed_signer_status(&identity_request(&dir)) + .expect("status"); + + assert!(!status.setup_needed); + assert_eq!( + status.blocking_reason.as_deref(), + Some("GITMUN_ERROR_SSH_ALLOWED_SIGNERS_MISSING_EMAIL") + ); + assert_eq!(status.reason, Some(SshAllowedSignerReason::MissingEmail)); +} + +#[test] +fn ssh_allowed_signer_reports_unresolved_signing_key() { + let dir = init_repo(); + set_local_identity( + &dir, + Some("test@gitmun.test"), + Some("missing_id_ed25519"), + "ssh", + ); + + let status = handler() + .get_ssh_allowed_signer_status(&identity_request(&dir)) + .expect("status"); + + assert!(!status.setup_needed); + assert_eq!( + status.reason, + Some(SshAllowedSignerReason::UnresolvedSigningKey) + ); + assert!( + status + .blocking_reason + .as_deref() + .unwrap_or_default() + .contains("GITMUN_ERROR_SSH_ALLOWED_SIGNERS_SIGNING_KEY_UNRESOLVED") + ); +} + #[test] fn commit_history_all_refs_includes_branch_outside_detached_head() { let dir = init_repo(); diff --git a/src/api/commands.ts b/src/api/commands.ts index c7809fe..9451dab 100644 --- a/src/api/commands.ts +++ b/src/api/commands.ts @@ -9,6 +9,7 @@ import type { CommitPrimaryAction, CommitVerification, CommitRequest, + CommitMessageRecovery, CreateBranchRequest, CherryPickRequest, CherryPickResult, @@ -22,10 +23,12 @@ import type { DiffRequest, FetchRequest, ExportPatchRequest, + ExportCommitPatchRequest, ExternalDiffTool, FileDiff, FileRequest, GitIdentity, + SshAllowedSignerStatus, HunkStageRequest, IdentityRequest, ImportPatchRequest, @@ -61,8 +64,6 @@ import type { AppUpdateChannel, AvailableUpdate, MicrosoftStoreUpdate, - MicrosoftStoreUpdateEvent, - MicrosoftStoreUpdateResult, UpdateDownloadEvent, RemoveRemoteRequest, RenameRemoteRequest, @@ -179,6 +180,10 @@ export function exportPatchFile(request: ExportPatchRequest): Promise("export_patch_file", {request}); } +export function exportCommitPatchFile(request: ExportCommitPatchRequest): Promise { + return invoke("export_commit_patch_file", {request}); +} + export function getRepoDiffTool(repoPath: string): Promise { return invoke("get_repo_diff_tool", {request: {repoPath}}); } @@ -243,6 +248,10 @@ export function commitChanges(repoPath: string, message: string, amend?: boolean return invoke("commit_changes", {request: {repoPath, message, amend}}); } +export function getCommitMessageRecovery(repoPath: string): Promise { + return invoke("get_commit_message_recovery", {request: {repoPath}}); +} + export function fetchRemote(repoPath: string, remote?: string): Promise { return invoke("fetch_remote", {request: {repoPath, remote}}); } @@ -368,6 +377,14 @@ export function setIdentity(request: SetIdentityRequest): Promise("set_identity", {request}); } +export function getSshAllowedSignerStatus(repoPath: string, scope: "Local" | "Global"): Promise { + return invoke("get_ssh_allowed_signer_status", {request: {repoPath, scope}}); +} + +export function addSshSigningKeyToAllowedSigners(repoPath: string, scope: "Local" | "Global"): Promise { + return invoke("add_ssh_signing_key_to_allowed_signers", {request: {repoPath, scope}}); +} + export function getTags(repoPath: string): Promise { return invoke("get_tags", {request: {repoPath}}); } @@ -520,17 +537,8 @@ export function checkMicrosoftStoreUpdate(): Promise("check_microsoft_store_update"); } -export function requestMicrosoftStoreUpdate(): Promise { - const onEvent = new Channel(); - return invoke("request_microsoft_store_update", {onEvent}); -} - -export function requestMicrosoftStoreUpdateWithProgress( - onProgress: (event: MicrosoftStoreUpdateEvent) => void, -): Promise { - const onEvent = new Channel(); - onEvent.onmessage = onProgress; - return invoke("request_microsoft_store_update", {onEvent}); +export function openMicrosoftStoreUpdatePage(): Promise { + return invoke("open_microsoft_store_update_page"); } export function downloadAndInstallAppUpdate(expectedVersion?: string): Promise { diff --git a/src/components/ProjectView.test.ts b/src/components/ProjectView.test.ts index a94e149..d53b885 100644 --- a/src/components/ProjectView.test.ts +++ b/src/components/ProjectView.test.ts @@ -1,6 +1,14 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import i18n from "../i18n"; -import { buildStashDropPrompt, getEffectiveCommitAction, shouldForceWithLeaseAfterRebase } from "./ProjectView"; +import type { BranchInfo, OperationResult } from "../types"; +import { + buildPushRequestForCurrentBranch, + buildStashDropPrompt, + getEffectiveCommitAction, + importPatchWithRecovery, + isPatchConflictResult, + shouldForceWithLeaseAfterRebase, +} from "./ProjectView"; const t = i18n.getFixedT("en", "projectView"); @@ -39,3 +47,140 @@ describe("shouldForceWithLeaseAfterRebase", () => { expect(shouldForceWithLeaseAfterRebase(null, "main")).toBe(false); }); }); + +describe("buildPushRequestForCurrentBranch", () => { + const trackedBranch: BranchInfo = { + name: "0.8.2-develop", + isCurrent: true, + isRemote: false, + upstream: "origin/0.9.0-develop", + upstreamStatus: "tracked", + ahead: 2, + behind: 0, + }; + + it("pushes explicitly to the tracked upstream branch", () => { + expect(buildPushRequestForCurrentBranch("/repo", trackedBranch, false, true)).toEqual({ + repoPath: "/repo", + forceWithLease: false, + pushFollowTags: true, + remote: "origin", + remoteBranch: "0.9.0-develop", + }); + }); + + it("falls back to the default push when the upstream is not parseable", () => { + expect(buildPushRequestForCurrentBranch( + "/repo", + { ...trackedBranch, upstream: "upstream-without-branch" }, + true, + false, + )).toEqual({ + repoPath: "/repo", + forceWithLease: true, + pushFollowTags: false, + }); + }); +}); + +function operationResult(message: string): OperationResult { + return { + message, + backendUsed: "git-cli", + output: null, + repoPath: "/repo", + }; +} + +function patchImportDeps(overrides: Partial[2]> = {}) { + return { + checkPatchFile: vi.fn(async () => operationResult("Patch file can be applied")), + importPatchFile: vi.fn(async () => operationResult("Applied patch file")), + confirmThreeWayApply: vi.fn(async () => false), + formatFailureMessage: (message: string) => `Import patch failed: ${message}`, + formatResultMessage: (message: string) => message, + appendLog: vi.fn(), + onApplied: vi.fn(async () => {}), + onConflicts: vi.fn(async () => {}), + onError: vi.fn(), + ...overrides, + }; +} + +describe("importPatchWithRecovery", () => { + it("keeps the normal import success path unchanged", async () => { + const deps = patchImportDeps(); + + const outcome = await importPatchWithRecovery("/repo", "/tmp/change.patch", deps); + + expect(outcome).toBe("applied"); + expect(deps.checkPatchFile).toHaveBeenCalledWith({ repoPath: "/repo", patchPath: "/tmp/change.patch" }); + expect(deps.importPatchFile).toHaveBeenCalledWith({ repoPath: "/repo", patchPath: "/tmp/change.patch" }); + expect(deps.confirmThreeWayApply).not.toHaveBeenCalled(); + expect(deps.onApplied).toHaveBeenCalledWith(operationResult("Applied patch file")); + expect(deps.appendLog).toHaveBeenCalledWith("success", "Applied patch file", "git-cli"); + }); + + it("prompts for 3-way retry after normal apply failure", async () => { + const deps = patchImportDeps({ + checkPatchFile: vi.fn(async () => { + throw new Error("patch does not apply"); + }), + }); + + const outcome = await importPatchWithRecovery("/repo", "/tmp/change.patch", deps); + + expect(outcome).toBe("cancelled"); + expect(deps.confirmThreeWayApply).toHaveBeenCalledWith("Error: patch does not apply"); + expect(deps.importPatchFile).not.toHaveBeenCalled(); + expect(deps.appendLog).toHaveBeenCalledWith( + "error", + "Import patch failed: Error: patch does not apply", + "unknown", + ); + }); + + it("retries with the 3-way flag when the user confirms", async () => { + const deps = patchImportDeps({ + checkPatchFile: vi.fn(async () => { + throw new Error("patch does not apply"); + }), + confirmThreeWayApply: vi.fn(async () => true), + }); + + const outcome = await importPatchWithRecovery("/repo", "/tmp/change.patch", deps); + + expect(outcome).toBe("applied"); + expect(deps.importPatchFile).toHaveBeenCalledWith({ + repoPath: "/repo", + patchPath: "/tmp/change.patch", + threeWay: true, + }); + expect(deps.onApplied).toHaveBeenCalledWith(operationResult("Applied patch file")); + }); + + it("routes 3-way conflict results to the conflict workflow", async () => { + const conflictResult = operationResult("GITMUN_PATCH_IMPORT_CONFLICTS"); + const deps = patchImportDeps({ + checkPatchFile: vi.fn(async () => { + throw new Error("patch does not apply"); + }), + confirmThreeWayApply: vi.fn(async () => true), + importPatchFile: vi.fn(async () => conflictResult), + }); + + const outcome = await importPatchWithRecovery("/repo", "/tmp/change.patch", deps); + + expect(outcome).toBe("conflicts"); + expect(deps.onConflicts).toHaveBeenCalledWith(conflictResult); + expect(deps.onApplied).not.toHaveBeenCalled(); + expect(deps.appendLog).toHaveBeenLastCalledWith("info", conflictResult.message, "git-cli"); + }); +}); + +describe("isPatchConflictResult", () => { + it("detects patch conflict operation results", () => { + expect(isPatchConflictResult(operationResult("GITMUN_PATCH_IMPORT_CONFLICTS"))).toBe(true); + expect(isPatchConflictResult(operationResult("Applied patch file"))).toBe(false); + }); +}); diff --git a/src/components/ProjectView.tsx b/src/components/ProjectView.tsx index 81af9ea..8190623 100644 --- a/src/components/ProjectView.tsx +++ b/src/components/ProjectView.tsx @@ -42,6 +42,7 @@ import { useGitStashes } from "../hooks/useGitStashes"; import * as api from "../api/commands"; import type { ResetMode } from "../api/commands"; import type { + BranchInfo, CommitLogScope, CommitMarkers, CommitPrimaryAction, @@ -49,6 +50,10 @@ import type { ExportPatchFileSelection, ExportPatchScope, GitIdentity, + ImportPatchRequest, + LongRunningOperation, + LongRunningOperationKind, + OperationResult, PullAnalysis, PullStrategy, PushRequest, @@ -56,14 +61,16 @@ import type { RemoteInfo, RepoOpenLocationKind, RowStriping, + StagingOperation, + StagingOperationKind, StashEntry, } from "../types"; -import type { ResultLogEntry } from "../utils/resultLog"; +import type { ResultLogEntry, ResultLogLevel } from "../utils/resultLog"; import { appendResultLog } from "../utils/resultLog"; import type { PlatformType } from "../hooks/usePlatform"; import type { ToastType } from "../hooks/useToast"; import { buildPushFailureDisplay } from "../utils/gitErrorDisplay"; -import { getRemoteActionState } from "../utils/remoteActionState"; +import { getRemoteActionState, splitUpstreamRef } from "../utils/remoteActionState"; import { displayNameForRepoPath } from "../utils/repoDisplayName"; // Tracks whether the no-diff-tool warning has already been shown this session @@ -76,6 +83,10 @@ const EMPTY_COMMIT_MARKERS: CommitMarkers = { upstreamRef: null, }; +function isStagingOperationKind(kind: LongRunningOperationKind): kind is StagingOperationKind { + return kind === "stage" || kind === "stageAll" || kind === "unstage" || kind === "unstageAll"; +} + function deriveLocalBranchName(remoteBranchName: string): string | null { const slashIndex = remoteBranchName.indexOf("/"); if (slashIndex <= 0 || slashIndex >= remoteBranchName.length - 1) { @@ -105,6 +116,20 @@ function getFileName(path: string): string { const BUNDLED_GIT_EXTERNAL_TOOL_ERROR = "GITMUN_BUNDLED_GIT_EXTERNAL_TOOL_UNSUPPORTED::"; const UNMERGED_BRANCH_DELETE_ERROR = "GITMUN_ERROR_UNMERGED_BRANCH_DELETE"; +const PATCH_EXPORT_NO_CHANGES = "GITMUN_ERROR_PATCH_EXPORT_NO_CHANGES"; +const PATCH_EXPORT_NO_COMMITS_SELECTED = "GITMUN_ERROR_PATCH_EXPORT_NO_COMMITS_SELECTED"; +const PATCH_EXPORT_ERROR_CODES = [ + "GITMUN_ERROR_PATCH_EXPORT_COMMIT_HASH_EMPTY", + "GITMUN_ERROR_PATCH_EXPORT_COMMIT_HASH_INVALID", + "GITMUN_ERROR_PATCH_EXPORT_COMMIT_HASH_INVALID_CHARACTER", +] as const; +const PATCH_IMPORT_APPLIED = "GITMUN_PATCH_IMPORT_APPLIED"; +const PATCH_IMPORT_CONFLICTS = "GITMUN_PATCH_IMPORT_CONFLICTS"; +const PATCH_IMPORT_MESSAGE_CODES = [ + PATCH_IMPORT_APPLIED, + PATCH_IMPORT_CONFLICTS, + "GITMUN_ERROR_PATCH_IMPORT_THREE_WAY_BLOCKED", +] as const; function localiseExternalToolError(error: unknown, t: TFunction<"projectView">): string { const message = String(error); @@ -117,6 +142,16 @@ function localiseExternalToolError(error: unknown, t: TFunction<"projectView">): return t("toast.bundledGitExternalToolUnsupported", { tool }); } +function localisePatchExportError(message: string, t: TFunction<"projectView">): string { + const code = PATCH_EXPORT_ERROR_CODES.find(code => message.includes(code)); + return code ? t(`patch.errors.${code}`) : message; +} + +function localisePatchImportMessage(message: string, t: TFunction<"projectView">): string { + const code = PATCH_IMPORT_MESSAGE_CODES.find(code => message.includes(code)); + return code ? t(`patch.import.${code}`) : message; +} + export function buildStashDropPrompt( stash: Pick, t: TFunction<"projectView">, @@ -142,6 +177,95 @@ export function shouldForceWithLeaseAfterRebase( return rebasedBranchAwaitingPush !== null && rebasedBranchAwaitingPush === currentBranch; } +export function buildPushRequestForCurrentBranch( + repoPath: string, + currentBranchInfo: BranchInfo | null | undefined, + forceWithLease: boolean, + pushFollowTags: boolean, +): PushRequest { + const request: PushRequest = { + repoPath, + forceWithLease, + pushFollowTags, + }; + + if (currentBranchInfo?.upstreamStatus !== "tracked") { + return request; + } + + const upstream = splitUpstreamRef(currentBranchInfo.upstream); + if (!upstream) { + return request; + } + + return { + ...request, + remote: upstream.remote, + remoteBranch: upstream.branch, + }; +} + +export type PatchImportOutcome = "applied" | "conflicts" | "cancelled" | "failed"; + +export type PatchImportRecoveryDependencies = { + checkPatchFile: (request: ImportPatchRequest) => Promise; + importPatchFile: (request: ImportPatchRequest) => Promise; + confirmThreeWayApply: (message: string) => Promise; + formatFailureMessage: (message: string) => string; + formatResultMessage: (message: string) => string; + appendLog: (level: ResultLogLevel, message: string, backend: ResultLogEntry["backend"]) => void; + onApplied: (result: OperationResult) => Promise; + onConflicts: (result: OperationResult) => Promise; + onError: (message: string) => void; +}; + +export function isPatchConflictResult(result: Pick): boolean { + return result.message.includes(PATCH_IMPORT_CONFLICTS); +} + +export async function importPatchWithRecovery( + repoPath: string, + patchPath: string, + deps: PatchImportRecoveryDependencies, +): Promise { + const request = { repoPath, patchPath }; + + try { + await deps.checkPatchFile(request); + const result = await deps.importPatchFile(request); + deps.appendLog("success", deps.formatResultMessage(result.message), result.backendUsed); + await deps.onApplied(result); + return "applied"; + } catch (error) { + const message = String(error); + deps.appendLog("error", deps.formatFailureMessage(message), "unknown"); + + const retry = await deps.confirmThreeWayApply(message); + if (!retry) { + return "cancelled"; + } + + try { + const result = await deps.importPatchFile({ ...request, threeWay: true }); + const conflicts = isPatchConflictResult(result); + deps.appendLog(conflicts ? "info" : "success", deps.formatResultMessage(result.message), result.backendUsed); + if (conflicts) { + await deps.onConflicts(result); + return "conflicts"; + } + + await deps.onApplied(result); + return "applied"; + } catch (retryError) { + const retryMessage = String(retryError); + const displayMessage = deps.formatResultMessage(retryMessage); + deps.onError(displayMessage); + deps.appendLog("error", deps.formatFailureMessage(displayMessage), "unknown"); + return "failed"; + } + } +} + export type ProjectViewProps = { /** The active repository path. Changing this key causes a full remount. */ repoPath: string | null; @@ -249,7 +373,9 @@ export function ProjectView({ const [showCreateTagDialog, setShowCreateTagDialog] = useState(false); const [createTagTarget, setCreateTagTarget] = useState(null); const [createBranchFromTagName, setCreateBranchFromTagName] = useState(null); - const [isCommitting, setIsCommitting] = useState(false); + const [operationLock, setOperationLock] = useState(null); + const operationLockRef = useRef(null); + const nextOperationIdRef = useRef(1); const [isRebaseActionRunning, setIsRebaseActionRunning] = useState(false); const [isCherryPickActionRunning, setIsCherryPickActionRunning] = useState(false); const [isRevertActionRunning, setIsRevertActionRunning] = useState(false); @@ -273,9 +399,9 @@ export function ProjectView({ )); const searchInputRef = useRef(null); - const { identity: localIdentity, saving: localIdentitySaving, saveIdentity: saveLocalIdentity } = + const { identity: localIdentity, saving: localIdentitySaving, saveIdentity: saveLocalIdentity, refreshIdentity: refreshLocalIdentity } = useGitIdentity(repoPath, "Local"); - const { identity: globalIdentity, saving: globalIdentitySaving, saveIdentity: saveGlobalIdentity } = + const { identity: globalIdentity, saving: globalIdentitySaving, saveIdentity: saveGlobalIdentity, refreshIdentity: refreshGlobalIdentity } = useGitIdentity(repoPath, "Global"); const [identityScope, setIdentityScope] = useState<"local" | "global">(() => { const stored = localStorage.getItem("gitmun.identityScope"); @@ -305,6 +431,7 @@ export function ProjectView({ const stagedFiles = status?.stagedFiles ?? []; const unstagedFiles = status?.changedFiles ?? []; const unversionedFiles = status?.unversionedFiles ?? []; + const unversionedItems = status?.unversionedItems; const selectedStagedPaths = stagedFiles .filter(file => selectedStagedFiles[file.path]) .map(file => file.path); @@ -339,6 +466,11 @@ export function ProjectView({ const forceWithLeaseAfterRebase = shouldForceWithLeaseAfterRebase(rebasedBranchAwaitingPush, currentBranch); const canCommitAndPush = currentBranchInfo?.upstreamStatus === "tracked"; const effectiveCommitAction = getEffectiveCommitAction(commitPrimaryAction, canCommitAndPush); + const stagingOperation: StagingOperation | null = + operationLock && isStagingOperationKind(operationLock.kind) + ? { kind: operationLock.kind, count: operationLock.count } + : null; + const isCommitting = operationLock?.kind === "commit" || operationLock?.kind === "commitAndPush"; const remoteActionLabel = remoteActionState.kind === "publish" ? t("remoteAction.publish", { ns: "git" }) : remoteActionState.kind === "repair-upstream" @@ -590,6 +722,49 @@ export function ProjectView({ }; }, [repoPath]); + const startOperation = useCallback((kind: LongRunningOperationKind, count?: number) => { + if (operationLockRef.current) { + return null; + } + + const operation: LongRunningOperation = { + id: nextOperationIdRef.current, + kind, + count, + startedAt: Date.now(), + }; + nextOperationIdRef.current += 1; + operationLockRef.current = operation; + setOperationLock(operation); + return operation; + }, []); + + const finishOperation = useCallback((operation: LongRunningOperation) => { + if (operationLockRef.current?.id !== operation.id) { + return; + } + + operationLockRef.current = null; + setOperationLock(null); + }, []); + + const runStagingOperation = useCallback(async ( + kind: StagingOperationKind, + count: number | undefined, + task: () => Promise, + ) => { + const operation = startOperation(kind, count); + if (!operation) { + return; + } + + try { + await task(); + } finally { + finishOperation(operation); + } + }, [finishOperation, startOperation]); + const handleFileSelect = useCallback((path: string, staged: boolean) => { setSelectedSubmodulePath(null); setSelectedFile(path); @@ -604,44 +779,56 @@ export function ProjectView({ const handleStageFile = useCallback(async (path: string) => { if (!repoPath) return; - try { + await runStagingOperation("stage", 1, async () => { const result = await api.stageFiles(repoPath, [path]); showToast(t("toast.stagedFiles", { count: 1, file: getFileName(path) })); appendResultLog("success", t("log.stagedFiles", { count: 1, path }), result.backendUsed); await refreshStatus(); - } catch (e) { showToast(String(e), "error"); appendResultLog("error", t("log.stageFailed", { message: String(e) }), "unknown"); } - }, [repoPath, refreshStatus, showToast, t]); + }).catch(e => { + showToast(String(e), "error"); + appendResultLog("error", t("log.stageFailed", { message: String(e) }), "unknown"); + }); + }, [repoPath, refreshStatus, runStagingOperation, showToast, t]); const handleStageFiles = useCallback(async (paths: string[]) => { if (!repoPath || paths.length === 0) return; - try { + await runStagingOperation("stage", paths.length, async () => { const result = await api.stageFiles(repoPath, paths); showToast(t("toast.stagedFiles", { count: paths.length, file: getFileName(paths[0]) })); appendResultLog("success", t("log.stagedFiles", { count: paths.length, path: paths[0] }), result.backendUsed); await refreshStatus(); - } catch (e) { showToast(String(e), "error"); appendResultLog("error", t("log.stageFailed", { message: String(e) }), "unknown"); } - }, [repoPath, refreshStatus, showToast, t]); + }).catch(e => { + showToast(String(e), "error"); + appendResultLog("error", t("log.stageFailed", { message: String(e) }), "unknown"); + }); + }, [repoPath, refreshStatus, runStagingOperation, showToast, t]); const handleUnstageFile = useCallback(async (path: string) => { if (!repoPath) return; - try { + await runStagingOperation("unstage", 1, async () => { const result = await api.unstageFile(repoPath, path); showToast(t("toast.unstagedFiles", { count: 1, file: getFileName(path) }), "info"); appendResultLog("info", t("log.unstagedFiles", { count: 1, path }), result.backendUsed); await refreshStatus(); - } catch (e) { showToast(String(e), "error"); appendResultLog("error", t("log.unstageFailed", { message: String(e) }), "unknown"); } - }, [repoPath, refreshStatus, showToast, t]); + }).catch(e => { + showToast(String(e), "error"); + appendResultLog("error", t("log.unstageFailed", { message: String(e) }), "unknown"); + }); + }, [repoPath, refreshStatus, runStagingOperation, showToast, t]); const handleUnstageFiles = useCallback(async (paths: string[]) => { if (!repoPath || paths.length === 0) return; - try { + await runStagingOperation("unstage", paths.length, async () => { const results = await Promise.all(paths.map(path => api.unstageFile(repoPath, path))); showToast(t("toast.unstagedFiles", { count: paths.length, file: getFileName(paths[0]) }), "info"); const backendUsed = results[0]?.backendUsed ?? "unknown"; appendResultLog("info", t("log.unstagedFiles", { count: paths.length, path: paths[0] }), backendUsed); await refreshStatus(); - } catch (e) { showToast(String(e), "error"); appendResultLog("error", t("log.unstageFailed", { message: String(e) }), "unknown"); } - }, [repoPath, refreshStatus, showToast, t]); + }).catch(e => { + showToast(String(e), "error"); + appendResultLog("error", t("log.unstageFailed", { message: String(e) }), "unknown"); + }); + }, [repoPath, refreshStatus, runStagingOperation, showToast, t]); const doRevertFiles = useCallback(async (paths: string[]) => { if (!repoPath) return; @@ -694,23 +881,29 @@ export function ProjectView({ const handleStageAll = useCallback(async () => { if (!repoPath) return; - try { + await runStagingOperation("stageAll", undefined, async () => { const result = await api.stageAll(repoPath); showToast(t("toast.stagedAll")); appendResultLog("success", t("log.stagedAll"), result.backendUsed); await refreshStatus(); - } catch (e) { showToast(String(e), "error"); appendResultLog("error", t("log.stageAllFailed", { message: String(e) }), "unknown"); } - }, [repoPath, refreshStatus, showToast, t]); + }).catch(e => { + showToast(String(e), "error"); + appendResultLog("error", t("log.stageAllFailed", { message: String(e) }), "unknown"); + }); + }, [repoPath, refreshStatus, runStagingOperation, showToast, t]); const handleUnstageAll = useCallback(async () => { if (!repoPath) return; - try { + await runStagingOperation("unstageAll", undefined, async () => { const result = await api.unstageAll(repoPath); showToast(t("toast.unstagedAll"), "info"); appendResultLog("info", t("log.unstagedAll"), result.backendUsed); await refreshStatus(); - } catch (e) { showToast(String(e), "error"); appendResultLog("error", t("log.unstageAllFailed", { message: String(e) }), "unknown"); } - }, [repoPath, refreshStatus, showToast, t]); + }).catch(e => { + showToast(String(e), "error"); + appendResultLog("error", t("log.unstageAllFailed", { message: String(e) }), "unknown"); + }); + }, [repoPath, refreshStatus, runStagingOperation, showToast, t]); const handleSelectCommitAction = useCallback(async (action: CommitPrimaryAction) => { if (action === commitPrimaryAction) { @@ -757,13 +950,17 @@ export function ProjectView({ }, [repoPath, rebaseInProgress, cherryPickInProgress, revertInProgress, refreshAll, showToast, t]); const handleCommit = useCallback(async (message: string, amend: boolean) => { - setIsCommitting(true); + const operation = startOperation("commit"); + if (!operation) { + return false; + } + try { - await runCommitRequest(message, amend); + return await runCommitRequest(message, amend); } finally { - setIsCommitting(false); + finishOperation(operation); } - }, [runCommitRequest]); + }, [finishOperation, runCommitRequest, startOperation]); const handleFetch = useCallback(async () => { if (!repoPath || remoteOp) return; @@ -903,25 +1100,35 @@ export function ProjectView({ return; } - await runPushRequest({ - repoPath, - forceWithLease: forceWithLeaseAfterRebase, - pushFollowTags, - }, t("toast.pushComplete"), t("toast.pushFailed")); - }, [forceWithLeaseAfterRebase, remoteActionState, remoteActionTitle, repoPath, remoteOp, runPushRequest, showToast, pushFollowTags, t]); + await runPushRequest( + buildPushRequestForCurrentBranch( + repoPath, + currentBranchInfo, + forceWithLeaseAfterRebase, + pushFollowTags, + ), + t("toast.pushComplete"), + t("toast.pushFailed"), + ); + }, [currentBranchInfo, forceWithLeaseAfterRebase, remoteActionState, remoteActionTitle, repoPath, remoteOp, runPushRequest, showToast, pushFollowTags, t]); const handleCommitAndPush = useCallback(async (message: string, amend: boolean) => { - setIsCommitting(true); + const operation = startOperation("commitAndPush"); + if (!operation) { + return false; + } + try { const committed = await runCommitRequest(message, amend); if (!committed) { - return; + return false; } await handlePush(); + return true; } finally { - setIsCommitting(false); + finishOperation(operation); } - }, [handlePush, runCommitRequest]); + }, [finishOperation, handlePush, runCommitRequest, startOperation]); const handleUpstreamDialogConfirm = useCallback(async (selection: { remote: string; remoteBranch: string }) => { if (!repoPath || !currentBranchInfo || !upstreamDialogMode) { @@ -1577,17 +1784,30 @@ export function ProjectView({ }); if (!confirmed) return; - try { - await api.checkPatchFile({ repoPath, patchPath: selected }); - const result = await api.importPatchFile({ repoPath, patchPath: selected }); - showToast(t("toast.patchImported"), "success"); - appendResultLog("success", result.message, result.backendUsed); - setCentreTab("changes"); - await refreshAll(); - } catch (e) { - showToast(String(e), "error"); - appendResultLog("error", t("log.importPatchFailed", { message: String(e) }), "unknown"); - } + await importPatchWithRecovery(repoPath, selected, { + checkPatchFile: api.checkPatchFile, + importPatchFile: api.importPatchFile, + confirmThreeWayApply: (message) => ask(t("ask.importPatchThreeWay.message", { message }), { + title: t("ask.importPatchThreeWay.title"), + kind: "warning", + okLabel: t("actions.tryThreeWayApply"), + cancelLabel: t("actions.cancel"), + }), + formatFailureMessage: (message) => t("log.importPatchFailed", { message }), + formatResultMessage: (message) => localisePatchImportMessage(message, t), + appendLog: appendResultLog, + onApplied: async () => { + showToast(t("toast.patchImported"), "success"); + setCentreTab("changes"); + await refreshAll(); + }, + onConflicts: async () => { + showToast(t("toast.patchImportedWithConflicts"), "info"); + setCentreTab("changes"); + await refreshAll(); + }, + onError: (message) => showToast(message, "error"), + }); }, [repoPath, refreshAll, showToast, t]); const handleExportPatch = useCallback(async (scope: ExportPatchScope) => { @@ -1610,19 +1830,52 @@ export function ProjectView({ const result = await api.exportPatchFile({ repoPath, patchPath: selected, scope, files }); const patchName = getFileName(selected); showToast(t("toast.patchExported", { file: patchName }), "success"); - appendResultLog("success", result.message, result.backendUsed); + appendResultLog("success", t("toast.patchExported", { file: patchName }), result.backendUsed); } catch (e) { const message = String(e); - if (message.includes("No changes available for patch export")) { + if (message.includes(PATCH_EXPORT_NO_CHANGES)) { showToast(t("toast.noPatchChanges"), "info"); appendResultLog("info", t("log.noPatchChanges"), "git-cli"); return; } - showToast(message, "error"); - appendResultLog("error", t("log.exportPatchFailed", { message }), "unknown"); + const displayMessage = localisePatchExportError(message, t); + showToast(displayMessage, "error"); + appendResultLog("error", t("log.exportPatchFailed", { message: displayMessage }), "unknown"); } }, [repoPath, selectedPatchFiles, showToast, t]); + const handleExportCommitPatch = useCallback(async (commitHashes: string[]) => { + if (!repoPath) return; + if (commitHashes.length === 0) { + showToast(t("toast.noPatchChanges"), "info"); + return; + } + + const selected = await save({ + title: t("patch.exportPickerTitle"), + defaultPath: "commits.patch", + filters: [{ name: t("patch.patchFilesFilter"), extensions: ["patch"] }], + }); + if (!selected) return; + + try { + const result = await api.exportCommitPatchFile({ repoPath, patchPath: selected, commitHashes }); + const patchName = getFileName(selected); + showToast(t("toast.patchExported", { file: patchName }), "success"); + appendResultLog("success", t("toast.patchExported", { file: patchName }), result.backendUsed); + } catch (e) { + const message = String(e); + if (message.includes(PATCH_EXPORT_NO_CHANGES) || message.includes(PATCH_EXPORT_NO_COMMITS_SELECTED)) { + showToast(t("toast.noPatchChanges"), "info"); + appendResultLog("info", t("log.noPatchChanges"), "git-cli"); + return; + } + const displayMessage = localisePatchExportError(message, t); + showToast(displayMessage, "error"); + appendResultLog("error", t("log.exportPatchFailed", { message: displayMessage }), "unknown"); + } + }, [repoPath, showToast, t]); + const runSubmoduleAction = useCallback(async ( path: string, label: string, @@ -2086,7 +2339,10 @@ export function ProjectView({ globalIdentitySaving={globalIdentitySaving} onSaveLocalIdentity={handleSaveLocalIdentity} onSaveGlobalIdentity={handleSaveGlobalIdentity} + onRefreshLocalIdentity={refreshLocalIdentity} + onRefreshGlobalIdentity={refreshGlobalIdentity} onScopeChange={setIdentityScope} + repoPath={repoPath} /> {showNoDiffToolWarning && ( @@ -2336,6 +2592,7 @@ export function ProjectView({ stagedFiles={stagedFiles} unstagedFiles={unstagedFiles} unversionedFiles={unversionedFiles} + unversionedItems={unversionedItems} submodules={submodules} conflictedFiles={conflictedFiles} mergeInProgress={mergeInProgress} @@ -2377,6 +2634,7 @@ export function ProjectView({ onCherryPickAtCommit={handleCherryPickAtCommit} onRevertAtCommit={handleRevertAtCommit} onResetToCommit={handleResetToCommit} + onExportCommitPatch={handleExportCommitPatch} selectedFile={selectedFile} selectedSubmodulePath={selectedSubmodulePath} selectedStagedFiles={selectedStagedFiles} @@ -2421,6 +2679,8 @@ export function ProjectView({ onConflictAcceptTheirs={handleConflictAcceptTheirs} onConflictAcceptOurs={handleConflictAcceptOurs} onOpenMergeTool={handleOpenMergeTool} + stagingOperation={stagingOperation} + operationLock={operationLock} isCommitting={isCommitting} isRebaseActionRunning={isRebaseActionRunning} isCherryPickActionRunning={isCherryPickActionRunning} diff --git a/src/components/centre/CentrePanel.css b/src/components/centre/CentrePanel.css index dd6352c..9c9a09a 100644 --- a/src/components/centre/CentrePanel.css +++ b/src/components/centre/CentrePanel.css @@ -3,6 +3,7 @@ display: flex; flex-direction: column; min-width: 0; + position: relative; } .centre__tabs { @@ -57,6 +58,58 @@ display: inline-block; } +.centre__operation-backdrop { + position: absolute; + inset: var(--panel-header-height) 0 0; + z-index: 40; + background: rgba(0, 0, 0, 0.22); +} + +.centre__operation-popup { + position: absolute; + z-index: 41; + left: 50%; + top: calc(var(--panel-header-height) + 28px); + transform: translateX(-50%); + width: min(360px, calc(100% - 32px)); + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-surface); + box-shadow: var(--shadow-dialog); +} + +.centre__operation-spinner { + width: 22px; + height: 22px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + flex-shrink: 0; +} + +.centre__operation-copy { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.centre__operation-title { + color: var(--text-primary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); +} + +.centre__operation-message { + color: var(--text-muted); + font-size: var(--font-size-xs); +} + /* Staging view */ .staging { flex: 1; @@ -65,15 +118,63 @@ overflow: hidden; } +.staging__operation-inline { + display: flex; + align-items: center; + gap: 10px; + margin: 10px 14px 0; + padding: 9px 11px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-surface); +} + +.staging__operation-spinner { + width: 16px; + height: 16px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + flex-shrink: 0; +} + +.staging__operation-copy { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.staging__operation-title { + color: var(--text-primary); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); +} + +.staging__operation-message { + color: var(--text-muted); + font-size: var(--font-size-xs); +} + .staging__files { flex: 1; - overflow: auto; + min-height: 0; + overflow: hidden; } .staging__section { padding: 10px 14px 4px; } +.staging__section--virtual { + padding-bottom: 2px; +} + +.staging__list-row { + padding: 0 14px; +} + .staging__section-header { display: flex; align-items: center; @@ -213,6 +314,11 @@ flex-shrink: 0; } +.file-row__badge--new { + background: rgba(52,211,153,0.12); + color: #34d399; +} + .file-row__path { flex: 1; font-family: var(--font-ui); @@ -241,6 +347,11 @@ align-items: center; } +.file-row__action-btn:disabled { + opacity: 0.45; + cursor: default; +} + .file-row__action-btn--accent { background: var(--accent-dim); color: var(--accent); @@ -492,6 +603,18 @@ .commit-box__hint--error { color: var(--red); } +.commit-box__restore { + border: 0; + background: transparent; + color: var(--accent); + font-size: var(--font-size-xxs); + font-family: var(--font-ui); + padding: 0; + cursor: pointer; +} + +.commit-box__restore:hover { text-decoration: underline; } + .commit-box__counter { font-size: var(--font-size-xxs); color: var(--text-muted); @@ -821,16 +944,31 @@ display: inline-flex; align-items: center; gap: 3px; - flex: 0 1 auto; min-width: 0; - max-width: min(42%, 150px); + max-width: 100%; overflow: hidden; } +.log-view__refs--inline { + flex: 0 1 auto; +} + +.log-view__refs--prominent { + display: flex; + width: 100%; + margin-top: 3px; +} + +.log-view__refs--prominent .log-view__ref { + transition: max-width 0.1s ease; +} + .log-view__ref { display: inline-flex; align-items: center; - max-width: 58px; + gap: 3px; + max-width: 14ch; + min-width: 0; padding: 1px 5px; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle); @@ -840,6 +978,16 @@ font-family: var(--font-mono); white-space: nowrap; overflow: hidden; +} + +.log-view__ref-marker { + flex-shrink: 0; + opacity: 0.75; +} + +.log-view__ref-label { + min-width: 0; + overflow: hidden; text-overflow: ellipsis; } @@ -849,13 +997,53 @@ border-color: var(--diff-add-border); } +.log-view__ref--head { + max-width: none; + color: var(--green); + background: var(--green-dim); + border-color: var(--diff-add-border); + flex-shrink: 0; +} + .log-view__ref--remoteBranch { + max-width: 24ch; + color: var(--accent); + background: var(--accent-dim); + border-color: var(--selection-border); +} + +.log-view__ref--upstream { + max-width: 24ch; color: var(--accent); background: var(--accent-dim); border-color: var(--selection-border); } +.log-view__refs--prominent .log-view__ref--remoteBranch, +.log-view__refs--prominent .log-view__ref--upstream { + max-width: 34ch; +} + +@media (hover: hover) and (pointer: fine) { + .log-view__refs--prominent:has(.log-view__ref:not(.log-view__ref--head):not(.log-view__ref--more):hover) + .log-view__ref:not(:hover):not(.log-view__ref--head):not(.log-view__ref--more) { + max-width: 10ch; + } + + .log-view__refs--prominent:has(.log-view__ref:not(.log-view__ref--head):not(.log-view__ref--more):hover) + .log-view__ref--remoteBranch:not(:hover), + .log-view__refs--prominent:has(.log-view__ref:not(.log-view__ref--head):not(.log-view__ref--more):hover) + .log-view__ref--upstream:not(:hover) { + max-width: 16ch; + } + + .log-view__refs--prominent .log-view__ref:hover:not(.log-view__ref--head):not(.log-view__ref--more) { + max-width: 42ch; + } +} + .log-view__ref--tag { + max-width: 14ch; color: var(--yellow); background: var(--bg-elevated); border-color: var(--yellow); @@ -879,28 +1067,7 @@ font-size: var(--font-size-xs); color: var(--accent); opacity: 0.7; -} - -.log-view__marker { - font-size: var(--font-size-xxs); - font-family: var(--font-mono); - font-weight: var(--font-weight-bold); - letter-spacing: 0.03em; - padding: 1px 6px; - border-radius: 999px; - border: 1px solid transparent; -} - -.log-view__marker--head { - color: var(--green); - background: var(--green-dim); - border-color: var(--diff-add-border); -} - -.log-view__marker--upstream { - color: var(--accent); - background: var(--accent-dim); - border-color: var(--selection-border); + flex-shrink: 0; } .log-view__sig-badge { @@ -1057,6 +1224,12 @@ margin-top: 1px; } +.sig-popover__body { + color: var(--text-secondary); + font-size: var(--font-size-xs); + line-height: 1.4; +} + .sig-popover__row { display: flex; flex-direction: column; @@ -1082,6 +1255,41 @@ font-size: var(--font-size-xxs); } +.sig-popover__value-with-action { + display: flex; + flex-direction: column; + gap: 4px; +} + +.sig-popover__copy, +.sig-popover__repair { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; + font-family: var(--font-ui); + font-size: var(--font-size-xxs); + font-weight: var(--font-weight-medium); +} + +.sig-popover__copy { + align-self: flex-start; + background: transparent; + color: var(--text-secondary); + padding: 2px 6px; +} + +.sig-popover__repair { + background: var(--accent); + border-color: var(--accent); + color: var(--bg); + padding: 5px 8px; +} + +.sig-popover__repair:disabled { + cursor: not-allowed; + opacity: 0.6; +} + .merge-banner { display: flex; align-items: center; diff --git a/src/components/centre/CentrePanel.test.tsx b/src/components/centre/CentrePanel.test.tsx index c67c44f..0aa1f8c 100644 --- a/src/components/centre/CentrePanel.test.tsx +++ b/src/components/centre/CentrePanel.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type React from "react"; import type { CommitHistoryItem } from "../../types"; @@ -7,7 +7,16 @@ import "../../i18n"; import { CentrePanel } from "./CentrePanel"; vi.mock("./StagingView", () => ({ - StagingView: () =>
, + StagingView: ({ inlineOperation }: { inlineOperation: { title: string; message: string } | null }) => ( +
+ {inlineOperation && ( +
+
{inlineOperation.title}
+
{inlineOperation.message}
+
+ )} +
+ ), })); vi.mock("./LogView", () => ({ @@ -125,6 +134,8 @@ function renderCentrePanel(overrides: Partial); + return { + ...render(), + props, + }; } describe("CentrePanel commit graph toggle", () => { @@ -183,3 +197,121 @@ describe("CentrePanel commit graph toggle", () => { expect(localStorage.getItem("gitmun.showCommitGraph")).toBe("true"); }); }); + +describe("CentrePanel operation feedback", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(0); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("shows no visible feedback for a fast operation under 500ms", () => { + renderCentrePanel({ + activeTab: "changes", + operationLock: { id: 1, kind: "stage", count: 42, startedAt: 0 }, + }); + + act(() => { + vi.advanceTimersByTime(499); + }); + + expect(screen.queryByTestId("inline-operation")).not.toBeInTheDocument(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("shows inline feedback after 500ms", () => { + renderCentrePanel({ + activeTab: "changes", + operationLock: { id: 1, kind: "stage", count: 42, startedAt: 0 }, + }); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(screen.getByTestId("inline-operation")).toHaveTextContent("Staging changes"); + expect(screen.getByTestId("inline-operation")).toHaveTextContent("Gitmun is staging 42 files."); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("escalates to the popup after 2500ms", () => { + renderCentrePanel({ + activeTab: "changes", + operationLock: { id: 1, kind: "stage", count: 42, startedAt: 0 }, + }); + + act(() => { + vi.advanceTimersByTime(2500); + }); + + expect(screen.getByRole("status")).toHaveTextContent("Staging changes"); + expect(screen.getByRole("status")).toHaveTextContent("This operation is still running."); + }); + + it("clears feedback when the operation completes", () => { + const { props, rerender } = renderCentrePanel({ + activeTab: "changes", + operationLock: { id: 1, kind: "stage", count: 42, startedAt: 0 }, + }); + + act(() => { + vi.advanceTimersByTime(2500); + }); + + rerender(); + + expect(screen.queryByTestId("inline-operation")).not.toBeInTheDocument(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("resets delayed feedback for a new operation id", () => { + const { props, rerender } = renderCentrePanel({ + activeTab: "changes", + operationLock: { id: 1, kind: "stage", count: 1, startedAt: 0 }, + }); + + act(() => { + vi.advanceTimersByTime(500); + }); + expect(screen.getByTestId("inline-operation")).toHaveTextContent("Staging changes"); + + rerender(); + expect(screen.queryByTestId("inline-operation")).not.toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(500); + }); + expect(screen.getByTestId("inline-operation")).toHaveTextContent("Unstaging changes"); + }); + + it("uses delayed feedback for commit and push", () => { + renderCentrePanel({ + activeTab: "changes", + operationLock: { id: 1, kind: "commitAndPush", startedAt: 0 }, + }); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(screen.getByTestId("inline-operation")).toHaveTextContent("Committing and pushing"); + expect(screen.getByTestId("inline-operation")).toHaveTextContent("Gitmun is creating the commit and pushing it to the remote."); + }); + + it("uses delayed feedback for commit", () => { + renderCentrePanel({ + activeTab: "changes", + operationLock: { id: 1, kind: "commit", startedAt: 0 }, + }); + + act(() => { + vi.advanceTimersByTime(2500); + }); + + expect(screen.getByRole("status")).toHaveTextContent("Creating commit"); + expect(screen.getByRole("status")).toHaveTextContent("This operation is still running."); + }); +}); diff --git a/src/components/centre/CentrePanel.tsx b/src/components/centre/CentrePanel.tsx index 1042f57..33edc90 100644 --- a/src/components/centre/CentrePanel.tsx +++ b/src/components/centre/CentrePanel.tsx @@ -1,4 +1,5 @@ import React from "react"; +import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { BranchIcon } from "../icons"; import { StagingView } from "./StagingView"; @@ -14,14 +15,19 @@ import type { CommitPrimaryAction, ConflictFileItem, FileStatusItem, + LongRunningOperation, + OperationFeedbackContent, RowStriping, + StagingOperation, SubmoduleStatus, + UnversionedItem, } from "../../types"; import "./CentrePanel.css"; export type CentreTab = "changes" | "log"; - const SHOW_COMMIT_GRAPH_KEY = "gitmun.showCommitGraph"; +const INLINE_OPERATION_DELAY_MS = 500; +const POPUP_OPERATION_DELAY_MS = 2500; function readShowCommitGraphPreference(): boolean { try { @@ -38,6 +44,7 @@ type CentrePanelProps = { stagedFiles: FileStatusItem[]; unstagedFiles: FileStatusItem[]; unversionedFiles: string[]; + unversionedItems?: UnversionedItem[]; submodules: SubmoduleStatus[]; conflictedFiles: ConflictFileItem[]; mergeInProgress: boolean; @@ -71,6 +78,7 @@ type CentrePanelProps = { onCherryPickAtCommit?: (commitHash: string) => void; onRevertAtCommit?: (commitHash: string) => void; onResetToCommit?: (commitHash: string, mode: "soft" | "mixed") => void; + onExportCommitPatch?: (commitHashes: string[]) => void; selectedFile: string | null; selectedSubmodulePath: string | null; selectedStagedFiles: Record; @@ -99,7 +107,7 @@ type CentrePanelProps = { commitMessageRecommendedLength: number; allowCommitAndPush: boolean; onSelectCommitAction: (action: CommitPrimaryAction) => void; - onCommit: (message: string, amend: boolean, action: CommitPrimaryAction) => void; + onCommit: (message: string, amend: boolean, action: CommitPrimaryAction) => boolean | Promise; onMergeAbort: () => void; onRebaseContinue: () => void; onRebaseAbort: () => void; @@ -110,6 +118,8 @@ type CentrePanelProps = { onConflictAcceptTheirs: (path: string) => void; onConflictAcceptOurs: (path: string) => void; onOpenMergeTool: (path: string) => void; + stagingOperation: StagingOperation | null; + operationLock: LongRunningOperation | null; isCommitting: boolean; isRebaseActionRunning: boolean; isCherryPickActionRunning: boolean; @@ -117,11 +127,95 @@ type CentrePanelProps = { lastCommitMessage: string; }; +function useDelayedOperationFeedback(operation: LongRunningOperation | null) { + const [now, setNow] = React.useState(() => Date.now()); + + React.useEffect(() => { + if (!operation) { + return; + } + + const update = () => setNow(Date.now()); + update(); + + const elapsed = Date.now() - operation.startedAt; + const inlineTimer = window.setTimeout(update, Math.max(0, INLINE_OPERATION_DELAY_MS - elapsed)); + const popupTimer = window.setTimeout(update, Math.max(0, POPUP_OPERATION_DELAY_MS - elapsed)); + + return () => { + window.clearTimeout(inlineTimer); + window.clearTimeout(popupTimer); + }; + }, [operation?.id, operation?.startedAt]); + + if (!operation) { + return { showInline: false, showPopup: false }; + } + + const elapsed = now - operation.startedAt; + return { + showInline: elapsed >= INLINE_OPERATION_DELAY_MS, + showPopup: elapsed >= POPUP_OPERATION_DELAY_MS, + }; +} + +function getOperationContent( + operation: LongRunningOperation | null, + t: TFunction<"centre">, +): OperationFeedbackContent | null { + if (!operation) return null; + + switch (operation.kind) { + case "stage": + return { + kind: operation.kind, + title: t("operation.stageTitle"), + message: t("operation.stageMessage", { count: operation.count ?? 0 }), + }; + case "stageAll": + return { + kind: operation.kind, + title: t("operation.stageAllTitle"), + message: t("operation.stageAllMessage"), + }; + case "unstage": + return { + kind: operation.kind, + title: t("operation.unstageTitle"), + message: t("operation.unstageMessage", { count: operation.count ?? 0 }), + }; + case "unstageAll": + return { + kind: operation.kind, + title: t("operation.unstageAllTitle"), + message: t("operation.unstageAllMessage"), + }; + case "commitAndPush": + return { + kind: operation.kind, + title: t("operation.commitAndPushTitle"), + message: t("operation.commitAndPushMessage"), + }; + case "commit": + return { + kind: operation.kind, + title: t("operation.commitTitle"), + message: t("operation.commitMessage"), + }; + } +} + export function CentrePanel(props: CentrePanelProps) { const { t } = useTranslation("centre"); const [showCommitGraph, setShowCommitGraph] = React.useState(readShowCommitGraphPreference); const effectiveShowCommitGraph = props.showCommitGraphButton && showCommitGraph; 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 + ? { ...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; @@ -141,7 +235,7 @@ export function CentrePanel(props: CentrePanelProps) { const message = props.mergeMessage?.split("\n").find(l => !l.startsWith("#"))?.trim() || props.mergeMessage?.trim() || ""; - props.onCommit(message, false, props.selectedCommitAction); + void props.onCommit(message, false, props.selectedCommitAction); }; return ( @@ -245,6 +339,7 @@ export function CentrePanel(props: CentrePanelProps) { stagedFiles={props.stagedFiles} unstagedFiles={props.unstagedFiles} unversionedFiles={props.unversionedFiles} + unversionedItems={props.unversionedItems} submodules={props.submodules} conflictedFiles={props.conflictedFiles} mergeInProgress={props.mergeInProgress} @@ -283,6 +378,8 @@ export function CentrePanel(props: CentrePanelProps) { onConflictAcceptTheirs={props.onConflictAcceptTheirs} onConflictAcceptOurs={props.onConflictAcceptOurs} onOpenMergeTool={props.onOpenMergeTool} + stagingOperation={props.stagingOperation} + inlineOperation={inlineOperationContent} isCommitting={props.isCommitting} lastCommitMessage={props.lastCommitMessage} rowStriping={props.rowStriping} @@ -312,8 +409,21 @@ export function CentrePanel(props: CentrePanelProps) { onCherryPickAtCommit={props.onCherryPickAtCommit} onRevertAtCommit={props.onRevertAtCommit} onResetToCommit={props.onResetToCommit} + onExportCommitPatch={props.onExportCommitPatch} />
+ {popupOperationContent && ( + <> +
+
+ + + )}
); } diff --git a/src/components/centre/CommitBox.test.tsx b/src/components/centre/CommitBox.test.tsx index e1762a5..f1b9c92 100644 --- a/src/components/centre/CommitBox.test.tsx +++ b/src/components/centre/CommitBox.test.tsx @@ -7,6 +7,15 @@ import type { CommitPrimaryAction } from "../../types"; import "../../i18n"; const COMMIT_BOX_RATIO_KEY = "gitmun.commitBoxRatio"; +const getCommitMessageRecovery = vi.fn(); + +vi.mock("../../api/commands", () => ({ + getCommitMessageRecovery: (...args: unknown[]) => getCommitMessageRecovery(...args), +})); + +function commitMessageDraftKey(repoPath: string) { + return `gitmun.commitMessageDraft.v1:${encodeURIComponent(repoPath)}`; +} function makeLocalStorage() { const store: Record = {}; @@ -22,6 +31,7 @@ function makeLocalStorage() { } type RenderCommitBoxOptions = { + repoPath?: string | null; selectedAction?: CommitPrimaryAction; commitMessageRecommendedLength?: number; allowCommitAndPush?: boolean; @@ -31,6 +41,7 @@ type RenderCommitBoxOptions = { }; function renderCommitBox({ + repoPath = "C:\\repo", selectedAction = "commit", commitMessageRecommendedLength = 72, allowCommitAndPush = true, @@ -44,6 +55,7 @@ function renderCommitBox({ const view = render(
{ beforeEach(() => { vi.stubGlobal("localStorage", makeLocalStorage()); localStorage.removeItem(COMMIT_BOX_RATIO_KEY); + getCommitMessageRecovery.mockClear(); + getCommitMessageRecovery.mockResolvedValue(null); }); afterEach(() => { @@ -82,6 +96,7 @@ describe("CommitBox", () => { rerender( { expect(onCommit).toHaveBeenCalledWith("Subject\n\nBody", false, "commit"); }); + it("saves normal commit message drafts while typing", async () => { + renderCommitBox({repoPath: "C:\\repo"}); + + fireEvent.change(screen.getByPlaceholderText("Commit subject..."), { + target: { value: "Draft subject" }, + }); + fireEvent.change(screen.getByPlaceholderText("Commit body..."), { + target: { value: "Draft body" }, + }); + + await waitFor(() => { + const draft = JSON.parse(localStorage.getItem(commitMessageDraftKey("C:\\repo")) ?? "null"); + expect(draft).toMatchObject({subject: "Draft subject", body: "Draft body"}); + }); + }); + + it("restores saved drafts for the same repo", () => { + localStorage.setItem(commitMessageDraftKey("C:\\repo"), JSON.stringify({ + subject: "Saved subject", + body: "Saved body", + updatedAt: 1, + })); + + renderCommitBox({repoPath: "C:\\repo"}); + + expect(screen.getByPlaceholderText("Commit subject...")).toHaveValue("Saved subject"); + expect(screen.getByPlaceholderText("Commit body...")).toHaveValue("Saved body"); + expect(getCommitMessageRecovery).not.toHaveBeenCalled(); + }); + + it("does not restore a saved draft over a merge message", () => { + localStorage.setItem(commitMessageDraftKey("C:\\repo"), JSON.stringify({ + subject: "Saved subject", + body: "Saved body", + updatedAt: 1, + })); + + renderCommitBox({ + repoPath: "C:\\repo", + mergeInProgress: true, + mergeMessage: "Merge branch 'main'\n\n# comment", + }); + + expect(screen.getByPlaceholderText("Commit subject...")).toHaveValue("Merge branch 'main'"); + expect(screen.getByPlaceholderText("Commit body...")).toHaveValue(""); + }); + + it("clears drafts after a successful commit", async () => { + const { onCommit } = renderCommitBox({repoPath: "C:\\repo"}); + onCommit.mockResolvedValue(true); + + fireEvent.change(screen.getByPlaceholderText("Commit subject..."), { + target: { value: "Subject" }, + }); + fireEvent.change(screen.getByPlaceholderText("Commit body..."), { + target: { value: "Body" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Commit (2)" })); + + await waitFor(() => { + expect(screen.getByPlaceholderText("Commit subject...")).toHaveValue(""); + expect(screen.getByPlaceholderText("Commit body...")).toHaveValue(""); + expect(localStorage.getItem(commitMessageDraftKey("C:\\repo"))).toBeNull(); + }); + }); + + it("keeps drafts visible after a failed commit", async () => { + const { onCommit } = renderCommitBox({repoPath: "C:\\repo"}); + onCommit.mockResolvedValue(false); + + fireEvent.change(screen.getByPlaceholderText("Commit subject..."), { + target: { value: "Subject" }, + }); + fireEvent.change(screen.getByPlaceholderText("Commit body..."), { + target: { value: "Body" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Commit (2)" })); + + await waitFor(() => expect(onCommit).toHaveBeenCalled()); + expect(screen.getByPlaceholderText("Commit subject...")).toHaveValue("Subject"); + expect(screen.getByPlaceholderText("Commit body...")).toHaveValue("Body"); + expect(localStorage.getItem(commitMessageDraftKey("C:\\repo"))).not.toBeNull(); + }); + + it("offers COMMIT_EDITMSG recovery when no local draft exists", async () => { + getCommitMessageRecovery.mockResolvedValue({message: "Recovered subject\n\nRecovered body", updatedAt: 1}); + renderCommitBox({repoPath: "C:\\repo"}); + + const restore = await screen.findByRole("button", {name: "Restore previous message"}); + fireEvent.click(restore); + + expect(screen.getByPlaceholderText("Commit subject...")).toHaveValue("Recovered subject"); + expect(screen.getByPlaceholderText("Commit body...")).toHaveValue("Recovered body"); + }); + + it("does not offer COMMIT_EDITMSG recovery for the latest commit message", async () => { + getCommitMessageRecovery.mockResolvedValue({message: "Latest subject\n\nLatest body", updatedAt: 1}); + renderCommitBox({ + repoPath: "C:\\repo", + lastCommitMessage: "Latest subject\n\nLatest body", + }); + + await waitFor(() => expect(getCommitMessageRecovery).toHaveBeenCalled()); + expect(screen.queryByRole("button", {name: "Restore previous message"})).not.toBeInTheDocument(); + }); + it("keeps commit disabled when only the body is present", () => { const { onCommit } = renderCommitBox(); diff --git a/src/components/centre/CommitBox.tsx b/src/components/centre/CommitBox.tsx index 14c95e8..d1873b1 100644 --- a/src/components/centre/CommitBox.tsx +++ b/src/components/centre/CommitBox.tsx @@ -2,15 +2,22 @@ import React, { useEffect, useRef, useState } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import type { CommitPrimaryAction } from "../../types"; +import { getCommitMessageRecovery } from "../../api/commands"; +import { + clearCommitMessageDraft, + loadCommitMessageDraft, + saveCommitMessageDraft, +} from "../../utils/commitMessageDraft"; import { CheckIcon, ChevDownIcon } from "../icons"; type CommitBoxProps = { + repoPath: string | null; stagedCount: number; selectedAction: CommitPrimaryAction; commitMessageRecommendedLength: number; allowCommitAndPush: boolean; onSelectAction: (action: CommitPrimaryAction) => void; - onCommit: (message: string, amend: boolean, action: CommitPrimaryAction) => void; + onCommit: (message: string, amend: boolean, action: CommitPrimaryAction) => boolean | Promise; isCommitting: boolean; lastCommitMessage: string; mergeMessage?: string | null; @@ -97,6 +104,7 @@ function getCommitBoxStorage(): Storage | null { } export function CommitBox({ + repoPath, stagedCount, selectedAction, commitMessageRecommendedLength, @@ -115,6 +123,8 @@ export function CommitBox({ const [body, setBody] = useState(""); const [amend, setAmend] = useState(false); const [menuOpen, setMenuOpen] = useState(false); + const [recoveryMessage, setRecoveryMessage] = useState(null); + const [draftReady, setDraftReady] = useState(false); const [commitBoxHeight, setCommitBoxHeight] = useState(null); const [dragState, setDragState] = useState(null); const menuRef = useRef(null); @@ -124,16 +134,63 @@ export function CommitBox({ const activeAction = allowCommitAndPush ? selectedAction : "commit"; useEffect(() => { + setDraftReady(false); + setRecoveryMessage(null); + if (mergeInProgress && mergeMessage) { const cleaned = mergeMessage.split("\n").filter(l => !l.startsWith("#")).join("\n").trim(); const nextMessage = splitCommitMessage(cleaned); setSubject(nextMessage.subject); setBody(nextMessage.body); - } else if (!mergeInProgress) { + setDraftReady(true); + return; + } + + if (mergeInProgress) { + setDraftReady(true); + return; + } + + if (!repoPath) { setSubject(""); setBody(""); + setDraftReady(true); + return; + } + + const draft = loadCommitMessageDraft(repoPath); + if (draft) { + setSubject(draft.subject); + setBody(draft.body); + setDraftReady(true); + return; } - }, [mergeInProgress, mergeMessage]); + + setSubject(""); + setBody(""); + setDraftReady(true); + + let cancelled = false; + getCommitMessageRecovery(repoPath) + .then(recovery => { + if (!cancelled) { + setRecoveryMessage(recovery && recovery.message !== lastCommitMessage ? recovery.message : null); + } + }) + .catch(() => { + if (!cancelled) { + setRecoveryMessage(null); + } + }); + return () => { + cancelled = true; + }; + }, [repoPath, mergeInProgress, mergeMessage, lastCommitMessage]); + + useEffect(() => { + if (!repoPath || !draftReady || mergeInProgress || amend) return; + saveCommitMessageDraft(repoPath, subject, body); + }, [repoPath, subject, body, draftReady, mergeInProgress, amend]); useEffect(() => { if (!menuOpen) return; @@ -229,14 +286,29 @@ export function CommitBox({ } }; - const handleCommit = () => { + const canRestoreRecovery = recoveryMessage != null && subject === "" && body === "" && !mergeInProgress && !amend; + + const handleRestoreRecovery = () => { + if (!recoveryMessage) return; + const nextMessage = splitCommitMessage(recoveryMessage); + setSubject(nextMessage.subject); + setBody(nextMessage.body); + setRecoveryMessage(null); + }; + + const handleCommit = async () => { if (actionDisabled) return; const message = trimmedBody === "" ? trimmedSubject : `${trimmedSubject}\n\n${trimmedBody}`; - onCommit(message, amend, activeAction); - setSubject(""); - setBody(""); - setAmend(false); - setMenuOpen(false); + const committed = await onCommit(message, amend, activeAction); + if (committed !== false) { + setSubject(""); + setBody(""); + setAmend(false); + setMenuOpen(false); + if (repoPath) { + clearCommitMessageDraft(repoPath); + } + } }; const handleCommitKeyDown = ( @@ -309,6 +381,15 @@ export function CommitBox({
+ {canRestoreRecovery && ( + + )} {subjectOverflow ? t("commitBox.subjectTooLong", {count: commitMessageRecommendedLength}) diff --git a/src/components/centre/FileRow.tsx b/src/components/centre/FileRow.tsx index a799c29..271e848 100644 --- a/src/components/centre/FileRow.tsx +++ b/src/components/centre/FileRow.tsx @@ -8,12 +8,14 @@ type FileRowProps = { isStaged: boolean; isSelected: boolean; checked?: boolean; + selectionDisabled?: boolean; onToggleChecked?: () => void; onSelect: () => void; onDoubleClick?: () => void; onStage?: () => void; onUnstage?: () => void; onDiscard?: () => void; + actionDisabled?: boolean; striped?: "Subtle" | "Strong"; displayPath?: string; titlePath?: string; @@ -33,12 +35,14 @@ export function FileRow({ isStaged, isSelected, checked, + selectionDisabled = false, onToggleChecked, onSelect, onDoubleClick, onStage, onUnstage, onDiscard, + actionDisabled = false, striped, displayPath, titlePath, @@ -64,8 +68,10 @@ export function FileRow({ className="file-row__check" type="checkbox" checked={checked ?? false} + disabled={selectionDisabled} onChange={(e) => { e.stopPropagation(); + if (selectionDisabled) return; onToggleChecked?.(); }} onClick={e => e.stopPropagation()} @@ -79,15 +85,15 @@ export function FileRow({ {hovered ? (
e.stopPropagation()}> {isStaged ? ( - ) : ( <> - - diff --git a/src/components/centre/LogView.test.tsx b/src/components/centre/LogView.test.tsx index c81472a..e4a2100 100644 --- a/src/components/centre/LogView.test.tsx +++ b/src/components/centre/LogView.test.tsx @@ -5,7 +5,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CommitHistoryItem, CommitMarkers, Settings } from "../../types"; import "../../i18n"; import { LogView } from "./LogView"; -import { verifyCommits } from "../../api/commands"; +import { + addSshSigningKeyToAllowedSigners, + getSshAllowedSignerStatus, + verifyCommits, +} from "../../api/commands"; vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(async () => null), @@ -30,6 +34,11 @@ const virtuosoCallbacksEnabled = vi.hoisted(() => ({ })); vi.mock("@tauri-apps/api/event", () => ({ + emit: vi.fn(async (event: string) => { + for (const listener of eventListeners.get(event) ?? []) { + listener({ payload: undefined }); + } + }), listen: vi.fn(async (event: string, callback: (event: { payload: unknown }) => void) => { const listeners = eventListeners.get(event) ?? []; listeners.push(callback); @@ -41,10 +50,28 @@ vi.mock("@tauri-apps/api/event", () => ({ }), })); +vi.mock("@tauri-apps/plugin-dialog", () => ({ + message: vi.fn(async () => "Ok"), +})); + vi.mock("../../api/commands", () => ({ + addSshSigningKeyToAllowedSigners: vi.fn(async () => ({ message: "Added", backendUsed: "git-cli" })), + getSshAllowedSignerStatus: vi.fn(async () => ({ + setupNeeded: false, + targetPath: null, + blockingReason: null, + allowedSignersConfigured: false, + allowedSignersExists: false, + signingKeyPresent: false, + signingKeyTrusted: false, + resolvedPublicKeyFingerprint: null, + reason: "notSsh", + })), verifyCommits: vi.fn(async () => []), })); +const mockAddSshSigningKeyToAllowedSigners = vi.mocked(addSshSigningKeyToAllowedSigners); +const mockGetSshAllowedSignerStatus = vi.mocked(getSshAllowedSignerStatus); const mockVerifyCommits = vi.mocked(verifyCommits); function emitEvent(event: string, payload: unknown = undefined) { @@ -201,6 +228,16 @@ function rowFor(subject: string): HTMLElement { return row as HTMLElement; } +function prominentRefLabelsFor(subject: string): string[] { + return Array.from(rowFor(subject).querySelectorAll(".log-view__refs--prominent .log-view__ref-label")) + .map(label => label.textContent ?? ""); +} + +function inlineRefLabelsFor(subject: string): string[] { + return Array.from(rowFor(subject).querySelectorAll(".log-view__meta .log-view__refs--inline .log-view__ref-label")) + .map(label => label.textContent ?? ""); +} + function renderLog(overrides: Partial> = {}) { const commits = overrides.commits ?? [commit(1), commit(2), commit(3)]; const commitMarkers: CommitMarkers = { @@ -244,6 +281,20 @@ describe("LogView commit selection", () => { virtuosoEndReached.current = null; virtuosoScrollToIndex.mockClear(); virtuosoCallbacksEnabled.current = true; + mockAddSshSigningKeyToAllowedSigners.mockReset(); + mockAddSshSigningKeyToAllowedSigners.mockResolvedValue({ message: "Added", backendUsed: "git-cli" }); + mockGetSshAllowedSignerStatus.mockReset(); + mockGetSshAllowedSignerStatus.mockResolvedValue({ + setupNeeded: false, + targetPath: null, + blockingReason: null, + allowedSignersConfigured: false, + allowedSignersExists: false, + signingKeyPresent: false, + signingKeyTrusted: false, + resolvedPublicKeyFingerprint: null, + reason: "notSsh", + }); mockVerifyCommits.mockReset(); mockVerifyCommits.mockResolvedValue([]); Object.defineProperty(navigator, "clipboard", { @@ -385,7 +436,7 @@ describe("LogView commit selection", () => { expect(rowFor("Subject 1")).toHaveTextContent("main"); }); - it("keeps commit ref decorations compact", () => { + it("shows up to four commit ref decorations before overflow", () => { renderLog({ commits: [ commit(1, { @@ -398,17 +449,16 @@ describe("LogView commit selection", () => { ], }); - expect(rowFor("Subject 1")).toHaveTextContent("main"); - expect(rowFor("Subject 1")).toHaveTextContent("v1.0.0"); - expect(rowFor("Subject 1")).toHaveTextContent("+1"); - expect(rowFor("Subject 1")).not.toHaveTextContent("origin/main"); + expect(prominentRefLabelsFor("Subject 1")).toEqual(["main", "origin/main", "v1.0.0"]); + expect(rowFor("Subject 1")).not.toHaveTextContent("+1"); }); - it("counts head and upstream markers in the compact ref limit", () => { + it("renders selected checkout refs on a prominent ref line", () => { const targetCommit = commit(1, { refDecorations: [ { name: "main", kind: "localBranch" }, { name: "origin/main", kind: "remoteBranch" }, + { name: "v1.0.0", kind: "tag" }, ], }); renderLog({ @@ -420,28 +470,124 @@ describe("LogView commit selection", () => { }, }); - const labels = Array.from(rowFor("Subject 1").querySelectorAll(".log-view__refs > span")) - .map(label => label.textContent); + const row = rowFor("Subject 1"); + const refs = Array.from(row.querySelectorAll(".log-view__refs--prominent > span")); + + expect(prominentRefLabelsFor("Subject 1")).toEqual(["HEAD", "main", "origin/main", "v1.0.0"]); + expect(inlineRefLabelsFor("Subject 1")).toEqual([]); + expect(refs.map(ref => Array.from(ref.classList))).toEqual([ + expect.arrayContaining(["log-view__ref--head"]), + expect.arrayContaining(["log-view__ref--localBranch"]), + expect.arrayContaining(["log-view__ref--upstream"]), + expect.arrayContaining(["log-view__ref--tag"]), + ]); + expect(screen.getByTitle("Current checkout")).toBeInTheDocument(); + expect(screen.getByTitle("Remote branch origin/main")).toBeInTheDocument(); + expect(row).not.toHaveTextContent("+1"); + }); + + it("keeps unselected non-checkout refs inline in the meta row", () => { + const targetCommit = commit(1, { + refDecorations: [ + { name: "main", kind: "localBranch" }, + { name: "origin/main", kind: "remoteBranch" }, + ], + }); + const selectedCommit = commit(2); + + renderLog({ + commits: [targetCommit, selectedCommit], + selectedCommitHash: selectedCommit.hash, + }); - expect(labels).toEqual(["HEAD", "origi...", "+1"]); - expect(screen.getByTitle("origin/main")).toBeInTheDocument(); - expect(screen.getByTitle("1 more refs: main")).toBeInTheDocument(); + expect(inlineRefLabelsFor("Subject 1")).toEqual(["main", "origin/main"]); + expect(prominentRefLabelsFor("Subject 1")).toEqual([]); }); - it("abbreviates long commit ref labels", () => { + it("keeps long remote ref labels visible and exposes the full label in the title", () => { + const remoteName = "origin/feature/long-branch-label"; + renderLog({ commits: [ commit(1, { refDecorations: [ - { name: "0.8.0-develop", kind: "localBranch" }, + { name: remoteName, kind: "remoteBranch" }, ], }), ], }); - expect(rowFor("Subject 1")).toHaveTextContent("0.8.0..."); - expect(rowFor("Subject 1")).not.toHaveTextContent("0.8.0-develop"); - expect(screen.getByTitle("Local branch 0.8.0-develop")).toBeInTheDocument(); + expect(prominentRefLabelsFor("Subject 1")).toEqual([remoteName]); + expect(screen.getByTitle(`Remote branch ${remoteName}`)).toBeInTheDocument(); + }); + + it("lists overflow refs in priority order", () => { + const targetCommit = commit(1, { + refDecorations: [ + { name: "main", kind: "localBranch" }, + { name: "release", kind: "localBranch" }, + { name: "origin/main", kind: "remoteBranch" }, + { name: "origin/feature-a", kind: "remoteBranch" }, + { name: "v1.0.0", kind: "tag" }, + { name: "v2.0.0", kind: "tag" }, + ], + }); + + renderLog({ + commits: [targetCommit], + commitMarkers: { + localHead: targetCommit.hash, + upstreamHead: null, + upstreamRef: null, + }, + }); + + expect(prominentRefLabelsFor("Subject 1")).toEqual(["HEAD", "main", "release", "origin/feature-a"]); + expect(screen.getByTitle("3 more refs: origin/main, v1.0.0, v2.0.0")).toBeInTheDocument(); + }); + + it("suppresses the duplicate upstream remote decoration on prominent ref rows", () => { + const targetCommit = commit(1, { + refDecorations: [ + { name: "main", kind: "localBranch" }, + { name: "origin/main", kind: "remoteBranch" }, + ], + }); + + renderLog({ + commits: [targetCommit], + commitMarkers: { + localHead: targetCommit.hash, + upstreamHead: targetCommit.hash, + upstreamRef: "origin/main", + }, + }); + + expect(prominentRefLabelsFor("Subject 1")).toEqual(["HEAD", "main", "origin/main"]); + expect(rowFor("Subject 1")).not.toHaveTextContent("+1"); + }); + + it("suppresses the duplicate upstream remote decoration on inline ref rows", () => { + const targetCommit = commit(1, { + refDecorations: [ + { name: "main", kind: "localBranch" }, + { name: "origin/main", kind: "remoteBranch" }, + ], + }); + const selectedCommit = commit(2); + + renderLog({ + commits: [targetCommit, selectedCommit], + selectedCommitHash: selectedCommit.hash, + commitMarkers: { + localHead: null, + upstreamHead: targetCommit.hash, + upstreamRef: "origin/main", + }, + }); + + expect(inlineRefLabelsFor("Subject 1")).toEqual(["main", "origin/main"]); + expect(prominentRefLabelsFor("Subject 1")).toEqual([]); }); it("renders an explicit load more footer instead of wiring automatic end reached loading", () => { @@ -851,6 +997,86 @@ describe("LogView commit selection", () => { expect(mockVerifyCommits).toHaveBeenLastCalledWith("/repo", [gpgCommit.hash]); }); + it("repairs SSH unknown-key signatures with the configured signing key", async () => { + const signedCommit = commit(1, { signatureStatus: "signed", keyType: "ssh" }); + mockVerifyCommits + .mockResolvedValueOnce([{ + hash: signedCommit.hash, + status: "unknownKey", + signer: "test@gitmun.test", + fingerprint: "SHA256:test", + }]) + .mockResolvedValueOnce([{ + hash: signedCommit.hash, + status: "verified", + signer: "test@gitmun.test", + fingerprint: "SHA256:test", + }]); + mockGetSshAllowedSignerStatus.mockResolvedValue({ + setupNeeded: true, + targetPath: "/repo/.git/gitmun_allowed_signers", + blockingReason: null, + allowedSignersConfigured: false, + allowedSignersExists: false, + signingKeyPresent: true, + signingKeyTrusted: false, + resolvedPublicKeyFingerprint: null, + reason: "untrustedSigningKey", + }); + + renderLog({ repoPath: "/repo", commits: [signedCommit] }); + + await screen.findByText("Signed"); + fireEvent.click(screen.getByRole("button", { name: "Signed" })); + const repairButton = await screen.findByRole("button", { name: "Add my SSH signing key" }); + fireEvent.click(repairButton); + + await waitFor(() => { + expect(mockGetSshAllowedSignerStatus).toHaveBeenCalledWith("/repo", "Local"); + expect(mockAddSshSigningKeyToAllowedSigners).toHaveBeenCalledWith("/repo", "Local"); + expect(mockVerifyCommits).toHaveBeenCalledTimes(2); + }); + }); + + it("does not offer arbitrary SSH commit trust without configured key material", async () => { + const signedCommit = commit(1, { signatureStatus: "signed", keyType: "ssh" }); + mockVerifyCommits.mockResolvedValue([{ + hash: signedCommit.hash, + status: "unknownKey", + signer: "test@gitmun.test", + fingerprint: "SHA256:test", + }]); + + renderLog({ repoPath: "/repo", commits: [signedCommit] }); + + await screen.findByText("Signed"); + fireEvent.click(screen.getByRole("button", { name: "Signed" })); + + await waitFor(() => expect(mockGetSshAllowedSignerStatus).toHaveBeenCalledWith("/repo", "Global")); + expect(screen.queryByRole("button", { name: "Add my SSH signing key" })).not.toBeInTheDocument(); + }); + + it("copies signature signer and fingerprint from the popover", async () => { + const signedCommit = commit(1, { signatureStatus: "signed", keyType: "ssh" }); + mockVerifyCommits.mockResolvedValue([{ + hash: signedCommit.hash, + status: "unknownKey", + signer: "test@gitmun.test", + fingerprint: "SHA256:test", + }]); + + renderLog({ repoPath: "/repo", commits: [signedCommit] }); + + await screen.findByText("Signed"); + fireEvent.click(screen.getByRole("button", { name: "Signed" })); + fireEvent.click(await screen.findByRole("button", { name: "Copy signer" })); + fireEvent.click(screen.getByRole("button", { name: "Copy fingerprint" })); + + expect(writeText).toHaveBeenCalledWith("test@gitmun.test"); + expect(writeText).toHaveBeenCalledWith("SHA256:test"); + expect(mockAddSshSigningKeyToAllowedSigners).not.toHaveBeenCalled(); + }); + it("ignores verification results from an older repo generation", async () => { const signedCommit = commit(1, { signatureStatus: "signed" }); const firstVerification = deferred>>(); @@ -1000,6 +1226,27 @@ describe("LogView commit selection", () => { ].join("\n")); }); + it("exports the selected commit patch", () => { + const onExportCommitPatch = vi.fn(); + renderLog({ onExportCommitPatch }); + + fireEvent.contextMenu(rowFor("Subject 1")); + fireEvent.click(screen.getByText("Export Commit Patch...")); + + expect(onExportCommitPatch).toHaveBeenCalledWith([commit(1).hash]); + }); + + it("exports selected commit patches in log order", () => { + const onExportCommitPatch = vi.fn(); + renderLog({ onExportCommitPatch }); + + fireEvent.click(rowFor("Subject 2"), { ctrlKey: true }); + fireEvent.contextMenu(rowFor("Subject 1")); + fireEvent.click(screen.getByText("Export Commit Patches...")); + + expect(onExportCommitPatch).toHaveBeenCalledWith([commit(1).hash, commit(2).hash]); + }); + it("right-clicking outside the selection selects only that commit", () => { renderLog(); diff --git a/src/components/centre/LogView.tsx b/src/components/centre/LogView.tsx index aa5773c..d02124c 100644 --- a/src/components/centre/LogView.tsx +++ b/src/components/centre/LogView.tsx @@ -1,6 +1,7 @@ import React, { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; +import { emit, listen } from "@tauri-apps/api/event"; +import { message } from "@tauri-apps/plugin-dialog"; import { useTranslation } from "react-i18next"; import { Virtuoso } from "react-virtuoso"; import type { ListItem, ListRange, VirtuosoHandle } from "react-virtuoso"; @@ -13,8 +14,13 @@ import type { RowStriping, Settings, SignatureStatus, + SshAllowedSignerStatus, } from "../../types"; -import { verifyCommits } from "../../api/commands"; +import { + addSshSigningKeyToAllowedSigners, + getSshAllowedSignerStatus, + verifyCommits, +} from "../../api/commands"; import { buildCommitGraph, type CommitGraphRow } from "../../utils/commitGraph"; import { ContextMenu } from "../shared/ContextMenu"; @@ -81,10 +87,34 @@ function ShieldIcon({ status }: { status: SignatureStatus }) { ); } -function SignaturePopover({ data, onClose }: { data: SigPopoverData; onClose: () => void }) { +function copyValue(value: string | null) { + if (!value) return; + navigator.clipboard?.writeText(value).catch(() => {}); +} + +function repairableStatus(status: SshAllowedSignerStatus | null): boolean { + return !!status?.setupNeeded && status.signingKeyPresent && !status.signingKeyTrusted; +} + +const ALLOWED_SIGNERS_ERROR_CODES = [ + "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_HOME_UNAVAILABLE", + "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_MISSING_EMAIL", + "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_NO_TARGET", + "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_SIGNING_KEY_MISSING", + "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_SIGNING_KEY_UNRESOLVED", +] as const; + +function extractAllowedSignersErrorCode(value: unknown): typeof ALLOWED_SIGNERS_ERROR_CODES[number] | null { + const message = String(value); + return ALLOWED_SIGNERS_ERROR_CODES.find(code => message.includes(code)) ?? null; +} + +function SignaturePopover({ data, repoPath, onClose }: { data: SigPopoverData; repoPath: string | null; onClose: () => void }) { const { t } = useTranslation("centre"); const ref = useRef(null); const { rect, status, signer, fingerprint, keyType, date } = data; + const [repairStatus, setRepairStatus] = useState<{ scope: "Local" | "Global"; status: SshAllowedSignerStatus } | null>(null); + const [repairLoading, setRepairLoading] = useState(false); // Position the popover above the badge, clamped to viewport const popoverWidth = 280; @@ -117,11 +147,61 @@ function SignaturePopover({ data, onClose }: { data: SigPopoverData; onClose: () }; }, [onClose]); + useEffect(() => { + let cancelled = false; + + async function loadRepairStatus() { + setRepairStatus(null); + if (!repoPath || status !== "unknownKey" || keyType !== "ssh") return; + + const local = await getSshAllowedSignerStatus(repoPath, "Local"); + if (cancelled) return; + if (repairableStatus(local)) { + setRepairStatus({ scope: "Local", status: local }); + return; + } + + const global = await getSshAllowedSignerStatus(repoPath, "Global"); + if (cancelled) return; + if (repairableStatus(global)) { + setRepairStatus({ scope: "Global", status: global }); + } + } + + loadRepairStatus().catch(() => {}); + return () => { + cancelled = true; + }; + }, [keyType, repoPath, status]); + + const handleAddAllowedSigner = async () => { + if (!repoPath || !repairStatus) return; + setRepairLoading(true); + try { + await addSshSigningKeyToAllowedSigners(repoPath, repairStatus.scope); + await emit("signature-settings-updated"); + onClose(); + } catch (error) { + const code = extractAllowedSignersErrorCode(error); + await message(code ? t(`log.allowedSignersErrors.${code}`) : String(error), { + title: t("log.allowedSignersRepairFailed"), + kind: "error", + }); + } finally { + setRepairLoading(false); + } + }; + const heading = status === "verified" ? t("log.signedVerified") : status === "bad" ? t("log.signedBad") : status === "unknownKey" ? t("log.signedUnknownKey") : t("log.signedUnverified"); + const explanation = status === "unknownKey" && keyType === "ssh" + ? repairStatus?.status.reason === "missingAllowedSignersFile" + ? t("log.sshAllowedSignersMissing") + : t("log.sshAllowedSignersUntrusted") + : null; const mod = status === "verified" ? "verified" : status === "bad" ? "bad" : "unknown"; @@ -132,10 +212,14 @@ function SignaturePopover({ data, onClose }: { data: SigPopoverData; onClose: () {heading}
+ {explanation &&
{explanation}
} {signer && (
{t("log.signer")} - {signer} + + {signer} + +
)} {keyType && ( @@ -147,13 +231,26 @@ function SignaturePopover({ data, onClose }: { data: SigPopoverData; onClose: () {fingerprint && (
{t("log.fingerprint")} - {formatFingerprint(fingerprint)} + + {formatFingerprint(fingerprint)} + +
)}
{t("log.date")} {new Date(date).toLocaleString()}
+ {repairStatus && ( + + )}
); } @@ -199,8 +296,7 @@ type CommitRowProps = { const GRAPH_LANE_WIDTH = 12; const GRAPH_NODE_RADIUS = 4; -const MAX_VISIBLE_REF_CHIPS = 2; -const MAX_REF_LABEL_LENGTH = 8; +const MAX_VISIBLE_REF_CHIPS = 4; const MAX_VERIFICATION_BATCH_SIZE = 20; const BACKGROUND_VERIFICATION_PUMP_DELAY_MS = 25; @@ -357,18 +453,9 @@ function refTitle( return t("log.tagRef", { name: decoration.name }); } -function refClass(kind: CommitRefKind): string { - return `log-view__ref log-view__ref--${kind}`; -} - -function compactRefName(name: string): string { - if (name.length <= MAX_REF_LABEL_LENGTH) return name; - return `${name.slice(0, MAX_REF_LABEL_LENGTH - 3)}...`; -} - function refPriority(kind: CommitRefKind): number { if (kind === "localBranch") return 0; - if (kind === "tag") return 1; + if (kind === "remoteBranch") return 1; return 2; } @@ -382,20 +469,30 @@ type CommitRefChip = { key: string; label: string; title: string; - className: string; - compact: boolean; + kind: CommitRefKind | "head" | "upstream"; + marker: string; }; +function chipPriority(kind: CommitRefChip["kind"]): number { + if (kind === "head") return 0; + if (kind === "localBranch") return 1; + if (kind === "upstream") return 2; + if (kind === "remoteBranch") return 3; + return 4; +} + function CommitRefChips({ decorations, isHead, isUpstream, upstreamRef, + variant = "inline", }: { decorations: CommitRefDecoration[]; isHead: boolean; isUpstream: boolean; upstreamRef: string | null | undefined; + variant?: "inline" | "prominent"; }) { const { t } = useTranslation("centre"); const markerNames = new Set(); @@ -405,9 +502,9 @@ function CommitRefChips({ chips.push({ key: "head", label: "HEAD", - title: "HEAD", - className: "log-view__ref log-view__marker log-view__marker--head", - compact: false, + title: t("log.currentCheckout"), + kind: "head", + marker: "@", }); } @@ -417,9 +514,9 @@ function CommitRefChips({ chips.push({ key: "upstream", label, - title: label, - className: "log-view__ref log-view__marker log-view__marker--upstream", - compact: true, + title: refTitle({ name: label, kind: "remoteBranch" }, t), + kind: "upstream", + marker: "U", }); } @@ -429,24 +526,28 @@ function CommitRefChips({ key: `${decoration.kind}-${decoration.name}`, label: decoration.name, title: refTitle(decoration, t), - className: refClass(decoration.kind), - compact: true, + kind: decoration.kind, + marker: decoration.kind === "localBranch" ? "L" : decoration.kind === "remoteBranch" ? "R" : "T", }); } if (chips.length === 0) return null; - const visible = chips.slice(0, MAX_VISIBLE_REF_CHIPS); - const hidden = chips.slice(MAX_VISIBLE_REF_CHIPS); + const orderedChips = chips.sort((a, b) => ( + chipPriority(a.kind) - chipPriority(b.kind) || a.label.localeCompare(b.label) + )); + const visible = orderedChips.slice(0, MAX_VISIBLE_REF_CHIPS); + const hidden = orderedChips.slice(MAX_VISIBLE_REF_CHIPS); return ( -
+
{visible.map(chip => ( - {chip.compact ? compactRefName(chip.label) : chip.label} + + {chip.label} ))} {hidden.length > 0 && ( @@ -492,6 +593,7 @@ const CommitRow = React.memo(function CommitRow({ e.preventDefault(); onContextMenu(c.hash, index, e.clientX, e.clientY); }, [onContextMenu, c.hash, index]); + const showProminentRefs = isSelected || isHead; useEffect(() => { if (c.signatureStatus === "signed") onVisibleSignedCommit(index); }, [c.hash, c.signatureStatus, index, onVisibleSignedCommit]); @@ -530,14 +632,25 @@ const CommitRow = React.memo(function CommitRow({
{c.message}
-
- {c.shortHash} + {showProminentRefs && ( + )} +
+ {c.shortHash} + {!showProminentRefs && ( + + )} onBadgeClick(rect, effectiveSigStatus, signer ?? null, fingerprint ?? null, c.keyType, c.date)} @@ -573,6 +686,7 @@ type LogViewProps = { onCherryPickAtCommit?: (commitHash: string) => void; onRevertAtCommit?: (commitHash: string) => void; onResetToCommit?: (commitHash: string, mode: "soft" | "mixed") => void; + onExportCommitPatch?: (commitHashes: string[]) => void; }; // Caps the burst of IPC calls on mount to avoid saturating the Tauri channel. @@ -679,6 +793,7 @@ export function LogView({ onCherryPickAtCommit, onRevertAtCommit, onResetToCommit, + onExportCommitPatch, }: LogViewProps) { const { t } = useTranslation("centre"); const [avatars, setAvatars] = useState>({}); @@ -1362,7 +1477,7 @@ export function LogView({ Footer, }} /> - {sigPopover && } + {sigPopover && } {commitMenu && commitMenuCommits.length > 0 && ( copyText(formatCommitDetails(commitMenuCommits)), }, + ...(onExportCommitPatch ? [ + { type: "separator" as const }, + { + label: t(commitMenuCommits.length === 1 ? "log.exportCommitPatch" : "log.exportCommitPatches"), + onClick: () => onExportCommitPatch(commitMenuCommits.map(c => c.hash)), + }, + ] : []), ...(commitMenuCommits.length === 1 && (onCherryPickAtCommit || onRevertAtCommit || onResetToCommit || onCreateTagAtCommit) ? [ { type: "separator" as const }, ] : []), diff --git a/src/components/centre/StagingView.test.tsx b/src/components/centre/StagingView.test.tsx index f3bce03..f323a81 100644 --- a/src/components/centre/StagingView.test.tsx +++ b/src/components/centre/StagingView.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { fireEvent, render, screen, within } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import React from "react"; import type { FileStatusItem } from "../../types"; @@ -7,9 +7,48 @@ import "../../i18n"; import { StagingView } from "./StagingView"; vi.mock("../../api/commands", () => ({ + getCommitMessageRecovery: vi.fn(async () => null), getNumstat: vi.fn(), })); +const virtuosoSetVisibleRange = vi.hoisted(() => ({ + current: null as ((range: { startIndex: number; endIndex: number }) => void) | null, +})); + +vi.mock("react-virtuoso", () => ({ + Virtuoso: ({ data, itemContent, computeItemKey }: { + data: Array<{ key: string }>; + itemContent: (index: number, item: { key: string }) => React.ReactNode; + computeItemKey?: (index: number, item: { key: string }) => React.Key; + }) => { + const [visibleRange, setVisibleRange] = React.useState({ startIndex: 0, endIndex: Math.min(data.length - 1, 19) }); + const visibleStart = Math.min(visibleRange.startIndex, Math.max(data.length - 1, 0)); + const visibleEnd = Math.min(visibleRange.endIndex, Math.max(data.length - 1, 0)); + const visibleItems = data.slice(visibleStart, visibleEnd + 1); + virtuosoSetVisibleRange.current = setVisibleRange; + + React.useEffect(() => { + setVisibleRange(current => ({ + startIndex: Math.min(current.startIndex, Math.max(data.length - 1, 0)), + endIndex: Math.min(Math.max(current.endIndex, 19), Math.max(data.length - 1, 0)), + })); + }, [data.length]); + + return ( +
+ {visibleItems.map((item, offset) => { + const index = visibleStart + offset; + return ( +
+ {itemContent(index, item)} +
+ ); + })} +
+ ); + }, +})); + function file(path: string, additions = 0, deletions = 0): FileStatusItem { return { path, @@ -19,6 +58,12 @@ function file(path: string, additions = 0, deletions = 0): FileStatusItem { }; } +function files(prefix: string, count: number): FileStatusItem[] { + return Array.from({ length: count }, (_, index) => file(`${prefix}/file-${String(index + 1).padStart(3, "0")}.ts`)); +} + +const untrackedDirectory = { path: "assets", kind: "directory" as const }; + function baseProps(overrides: Partial> = {}): React.ComponentProps { return { repoPath: "/repo", @@ -63,6 +108,8 @@ function baseProps(overrides: Partial> onConflictAcceptTheirs: vi.fn(), onConflictAcceptOurs: vi.fn(), onOpenMergeTool: vi.fn(), + stagingOperation: null, + inlineOperation: null, isCommitting: false, lastCommitMessage: "", rowStriping: "Off", @@ -153,6 +200,54 @@ describe("StagingView file tree", () => { expect(onStageFiles).toHaveBeenCalledWith(["src/App.tsx", "src/components/Button.tsx"]); }); + it("disables bulk staging actions while staging is running", () => { + renderStagingView({ + unstagedFiles: [file("src/App.tsx")], + selectedUnstaged: { "src/App.tsx": true }, + stagingOperation: { kind: "stage", count: 1 }, + }); + + expect(screen.getByText("Stage Selected")).toBeDisabled(); + expect(screen.getByText("Stage All")).toBeDisabled(); + expect(screen.getByLabelText("Deselect files in src")).toBeDisabled(); + }); + + it("renders an untracked directory row with directory copy", () => { + renderStagingView({ + unversionedFiles: ["assets"], + unversionedItems: [untrackedDirectory], + }); + + expect(screen.getByText("assets")).toBeInTheDocument(); + expect(screen.getByText("Untracked directory")).toBeInTheDocument(); + expect(screen.queryByLabelText("Expand assets")).not.toBeInTheDocument(); + }); + + it("stages a selected untracked directory path", () => { + const onStageFiles = vi.fn(); + render(); + + fireEvent.click(screen.getByLabelText("Select files in assets")); + fireEvent.click(screen.getByText("Stage Selected")); + + expect(onStageFiles).toHaveBeenCalledWith(["assets"]); + }); + + it("disables untracked directory selection while staging is running", () => { + renderStagingView({ + unversionedFiles: ["assets"], + unversionedItems: [untrackedDirectory], + stagingOperation: { kind: "stage", count: 1 }, + }); + + expect(screen.getByLabelText("Select files in assets")).toBeDisabled(); + expect(screen.getByText("Stage All")).toBeDisabled(); + }); + it("selects every descendant from a compact folder row", () => { const onStageFiles = vi.fn(); render( { unstagedFiles: [file("src/Unstaged.tsx")], }); - const stagedSection = screen.getByText(/Staged . 1 file/).closest(".staging__section"); - expect(stagedSection).not.toBeNull(); - - fireEvent.click(within(stagedSection as HTMLElement).getByLabelText("Collapse src")); + fireEvent.click(screen.getAllByLabelText("Collapse src")[0]); expect(screen.queryByText("Staged.tsx")).not.toBeInTheDocument(); expect(screen.getByText("Unstaged.tsx")).toBeInTheDocument(); @@ -263,15 +355,13 @@ describe("StagingView file tree", () => { rowStriping: "Strong", }); - const stagedSection = screen.getByText(/Staged . 2 files/).closest(".staging__section"); - const unstagedSection = screen.getByText(/Unstaged . 2 files/).closest(".staging__section"); - expect(stagedSection).not.toBeNull(); - expect(unstagedSection).not.toBeNull(); + const aRows = screen.getAllByText("A.ts").map(row => row.closest(".file-row")); + const bRows = screen.getAllByText("B.ts").map(row => row.closest(".file-row")); - expect(within(stagedSection as HTMLElement).getByText("A.ts").closest(".file-row")).not.toHaveClass("file-row--striped-strong"); - expect(within(stagedSection as HTMLElement).getByText("B.ts").closest(".file-row")).toHaveClass("file-row--striped-strong"); - expect(within(unstagedSection as HTMLElement).getByText("A.ts").closest(".file-row")).not.toHaveClass("file-row--striped-strong"); - expect(within(unstagedSection as HTMLElement).getByText("B.ts").closest(".file-row")).toHaveClass("file-row--striped-strong"); + expect(aRows[0]).not.toHaveClass("file-row--striped-strong"); + expect(bRows[0]).toHaveClass("file-row--striped-strong"); + expect(aRows[1]).not.toHaveClass("file-row--striped-strong"); + expect(bRows[1]).toHaveClass("file-row--striped-strong"); }); it("does not stripe files when row striping is off", () => { @@ -283,4 +373,62 @@ describe("StagingView file tree", () => { expect(screen.getByText("A.ts").closest(".file-row")).not.toHaveClass("file-row--striped-subtle", "file-row--striped-strong"); expect(screen.getByText("B.ts").closest(".file-row")).not.toHaveClass("file-row--striped-subtle", "file-row--striped-strong"); }); + + it("keeps folder rows expanded below the large-section threshold", () => { + renderStagingView({ + unstagedFiles: files("src", 20), + }); + + expect(screen.getByLabelText("Collapse src")).toBeInTheDocument(); + expect(screen.getByText("file-001.ts")).toBeInTheDocument(); + }); + + it("collapses top-level folders by default above the large-section threshold", () => { + renderStagingView({ + unstagedFiles: files("bulk", 501), + }); + + expect(screen.getByLabelText("Expand bulk")).toBeInTheDocument(); + expect(screen.queryByText("file-001.ts")).not.toBeInTheDocument(); + }); + + it("allows manual expansion to override large-list folder defaults", () => { + renderStagingView({ + unstagedFiles: files("bulk", 501), + }); + + fireEvent.click(screen.getByLabelText("Expand bulk")); + + expect(screen.getByLabelText("Collapse bulk")).toBeInTheDocument(); + expect(screen.getByText("file-001.ts")).toBeInTheDocument(); + }); + + it("selects every descendant from a collapsed large folder row", () => { + const onStageFiles = vi.fn(); + render(); + + fireEvent.click(screen.getByLabelText("Select files in bulk")); + fireEvent.click(screen.getByText("Stage Selected")); + + expect(onStageFiles).toHaveBeenCalledWith(files("bulk", 501).map(f => f.path)); + }); + + it("virtualises large root-level file lists without hiding later rows", () => { + renderStagingView({ + unstagedFiles: Array.from({ length: 600 }, (_, index) => file(`root-${String(index + 1).padStart(3, "0")}.ts`)), + }); + + expect(screen.getByTestId("staging-virtuoso")).toBeInTheDocument(); + expect(screen.getByText("root-001.ts")).toBeInTheDocument(); + expect(screen.queryByText("root-600.ts")).not.toBeInTheDocument(); + + act(() => { + virtuosoSetVisibleRange.current?.({ startIndex: 580, endIndex: 603 }); + }); + + expect(screen.getByText("root-600.ts")).toBeInTheDocument(); + }); }); diff --git a/src/components/centre/StagingView.tsx b/src/components/centre/StagingView.tsx index a04a793..370acfa 100644 --- a/src/components/centre/StagingView.tsx +++ b/src/components/centre/StagingView.tsx @@ -1,8 +1,18 @@ import React, { useEffect, useMemo, useState } from "react"; +import { Virtuoso } from "react-virtuoso"; import { useTranslation } from "react-i18next"; import { FileRow } from "./FileRow"; import { CommitBox } from "./CommitBox"; -import type { CommitPrimaryAction, ConflictFileItem, FileStatusItem, RowStriping, SubmoduleStatus } from "../../types"; +import type { + CommitPrimaryAction, + ConflictFileItem, + FileStatusItem, + OperationFeedbackContent, + RowStriping, + StagingOperation, + SubmoduleStatus, + UnversionedItem, +} from "../../types"; import { getNumstat } from "../../api/commands"; import { buildFileTree, descendantFilePaths, type FileTreeDirectoryNode, type FileTreeNode } from "../../utils/fileTree"; import { ChevDownIcon, ChevRightIcon, FolderIcon } from "../icons"; @@ -12,6 +22,7 @@ type StagingViewProps = { stagedFiles: FileStatusItem[]; unstagedFiles: FileStatusItem[]; unversionedFiles: string[]; + unversionedItems?: UnversionedItem[]; submodules: SubmoduleStatus[]; conflictedFiles: ConflictFileItem[]; mergeInProgress: boolean; @@ -46,10 +57,12 @@ type StagingViewProps = { commitMessageRecommendedLength: number; allowCommitAndPush: boolean; onSelectCommitAction: (action: CommitPrimaryAction) => void; - onCommit: (message: string, amend: boolean, action: CommitPrimaryAction) => void; + onCommit: (message: string, amend: boolean, action: CommitPrimaryAction) => boolean | Promise; onConflictAcceptTheirs: (path: string) => void; onConflictAcceptOurs: (path: string) => void; onOpenMergeTool: (path: string) => void; + stagingOperation: StagingOperation | null; + inlineOperation: OperationFeedbackContent | null; isCommitting: boolean; lastCommitMessage: string; rowStriping: RowStriping; @@ -64,11 +77,20 @@ type CachedNumstat = { type TreeSection = "staged" | "unstaged"; type VisibleTreeRow = - | { type: "directory"; node: FileTreeDirectoryNode; depth: number } + | { 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: "empty"; key: string; section: TreeSection }; + const NUMSTAT_REFRESH_MS = 7000; const NUMSTAT_BATCH_SIZE = 6; +const AUTO_COLLAPSE_SECTION_THRESHOLD = 500; +const AUTO_COLLAPSE_DIRECTORY_THRESHOLD = 100; const SUBMODULE_STATE_LABELS: Record = { clean: "Clean", @@ -88,10 +110,27 @@ 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; @@ -103,9 +142,9 @@ function visibleTreeRows( return [row]; } - const expanded = expandedFolders[folderStateKey(section, node.path)] ?? true; + 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 }, ...children]; + return [{ type: "directory", node, depth, expanded }, ...children]; }); return visit(nodes, 0); @@ -115,6 +154,18 @@ function shortHash(hash: string | null): string { return hash ? hash.slice(0, 8) : "-"; } +function OperationInlineFeedback({ operation }: { operation: OperationFeedbackContent }) { + return ( +
+ + ); +} + type SubmoduleRowProps = { submodule: SubmoduleStatus; selected: boolean; @@ -187,6 +238,7 @@ type DirectoryRowProps = { expanded: boolean; checked: boolean; indeterminate: boolean; + disabled: boolean; onToggleExpanded: () => void; onToggleChecked: () => void; }; @@ -197,6 +249,7 @@ function DirectoryRow({ expanded, checked, indeterminate, + disabled, onToggleExpanded, onToggleChecked, }: DirectoryRowProps) { @@ -220,30 +273,39 @@ function DirectoryRow({ className="file-row__check" type="checkbox" checked={checked} + disabled={disabled} aria-label={t(checked ? "staging.deselectFolderFiles" : "staging.selectFolderFiles", { path: directory.path, })} onChange={(e) => { e.stopPropagation(); + if (disabled) return; onToggleChecked(); }} onClick={e => e.stopPropagation()} /> - + {directory.children.length > 0 && ( + + )} + {directory.status === "new" && A} {directory.name} - {t("fileCount", { ns: "common", count: directory.fileCount })} + + {directory.selectablePath && directory.children.length === 0 + ? t("staging.untrackedDirectory") + : t("fileCount", { ns: "common", count: directory.fileCount })} + {directory.additions > 0 && ( +{directory.additions} @@ -258,14 +320,14 @@ function DirectoryRow({ export function StagingView({ repoPath, - stagedFiles, unstagedFiles, unversionedFiles, submodules, conflictedFiles, mergeInProgress, mergeMessage, rebaseInProgress, cherryPickInProgress, + stagedFiles, unstagedFiles, unversionedFiles, unversionedItems, submodules, conflictedFiles, mergeInProgress, mergeMessage, rebaseInProgress, cherryPickInProgress, selectedFile, selectedSubmodulePath, selectedUnstaged, selectedStaged, onSelectedUnstagedChange, onSelectedStagedChange, onFileSelect, onSubmoduleSelect, onSubmoduleInit, onSubmoduleUpdate, onSubmoduleSync, onSubmoduleFetch, onSubmodulePull, onSubmoduleOpen, onStageFile, onStageFiles, onUnstageFile, onUnstageFiles, onDiscardFile, onDiscardFiles, onDiscardAll, onExternalDiff, onStageAll, onUnstageAll, selectedCommitAction, commitMessageRecommendedLength, allowCommitAndPush, onSelectCommitAction, onCommit, onConflictAcceptTheirs, onConflictAcceptOurs, onOpenMergeTool, - isCommitting, lastCommitMessage, rowStriping, + stagingOperation, inlineOperation, isCommitting, lastCommitMessage, rowStriping, }: StagingViewProps) { const { t } = useTranslation("centre"); const [numstatCache, setNumstatCache] = useState>({}); @@ -301,9 +363,10 @@ export function StagingView({ const allUnstaged: FileStatusItem[] = useMemo( () => [ ...mergedUnstaged, - ...unversionedFiles.map(path => ({ path, status: "new", additions: null, deletions: null })), + ...(unversionedItems ?? unversionedFiles.map(path => ({ path, kind: "file" as const }))) + .map(item => ({ path: item.path, kind: item.kind, status: "new", additions: null, deletions: null })), ], - [mergedUnstaged, unversionedFiles], + [mergedUnstaged, unversionedFiles, unversionedItems], ); useEffect(() => { @@ -396,19 +459,23 @@ export function StagingView({ const stagedTree = useMemo(() => buildFileTree(mergedStaged), [mergedStaged]); const unstagedTree = useMemo(() => buildFileTree(allUnstaged), [allUnstaged]); const stagedTreeRows = useMemo( - () => visibleTreeRows(stagedTree, "staged", expandedFolders), - [stagedTree, expandedFolders], + () => visibleTreeRows(stagedTree, "staged", expandedFolders, mergedStaged.length), + [stagedTree, expandedFolders, mergedStaged.length], ); const unstagedTreeRows = useMemo( - () => visibleTreeRows(unstagedTree, "unstaged", expandedFolders), - [unstagedTree, expandedFolders], + () => visibleTreeRows(unstagedTree, "unstaged", expandedFolders, allUnstaged.length), + [unstagedTree, expandedFolders, allUnstaged.length], ); + const stagingBusy = stagingOperation != null; + const inlineOperationIsCommit = inlineOperation?.kind === "commit" || inlineOperation?.kind === "commitAndPush"; const toggleUnstaged = (path: string) => { + if (stagingBusy) return; onSelectedUnstagedChange(prev => ({ ...prev, [path]: !prev[path] })); }; const toggleStaged = (path: string) => { + if (stagingBusy) return; onSelectedStagedChange(prev => ({ ...prev, [path]: !prev[path] })); }; @@ -425,7 +492,14 @@ export function StagingView({ }; const toggleFolderExpanded = (section: TreeSection, path: string) => { const key = folderStateKey(section, path); - setExpandedFolders(prev => ({ ...prev, [key]: !(prev[key] ?? true) })); + setExpandedFolders(prev => { + const treeRows = section === "staged" ? stagedTreeRows : unstagedTreeRows; + const currentRow = treeRows.find(row => row.type === "directory" && row.node.path === path); + const currentExpanded = currentRow?.type === "directory" + ? currentRow.expanded + : prev[key] ?? true; + return { ...prev, [key]: !currentExpanded }; + }); }; const striped = (index: number): "Subtle" | "Strong" | undefined => { if (rowStriping === "Off" || index % 2 === 0) return undefined; @@ -445,9 +519,10 @@ export function StagingView({ toggleFolderExpanded(section, row.node.path)} onToggleChecked={() => { onSelectedChange(prev => { @@ -476,6 +551,7 @@ export function StagingView({ isSelected={selectedFile === f.path} striped={striped(row.fileIndex)} checked={selectedMap[f.path] ?? false} + selectionDisabled={stagingBusy} displayPath={row.depth > 0 ? row.node.name : undefined} titlePath={row.depth > 0 ? f.path : undefined} depth={row.depth} @@ -485,168 +561,260 @@ export function StagingView({ onStage={isStaged ? undefined : () => onStageFile(f.path)} onUnstage={isStaged ? () => onUnstageFile(f.path) : undefined} onDiscard={isStaged ? undefined : () => onDiscardFile(f.path)} + actionDisabled={stagingBusy} />
); }; - return ( -
-
- {submodules.length > 0 && ( -
-
- - {t("staging.submodules")} {"\u00B7"} {submodules.length} - -
- {submodules.map((submodule, index) => ( -
- onSubmoduleSelect(submodule.path)} - onInit={() => onSubmoduleInit(submodule.path)} - onUpdate={() => onSubmoduleUpdate(submodule.path)} - onSync={() => onSubmoduleSync(submodule.path)} - onFetch={() => onSubmoduleFetch(submodule.path)} - onPull={() => onSubmodulePull(submodule.path)} - onOpen={() => onSubmoduleOpen(submodule.path)} - /> -
- ))} -
- )} + const stagingRows = useMemo(() => { + const rows: StagingListRow[] = []; + + if (submodules.length > 0) { + rows.push({ type: "section", key: "section:submodules", section: "submodules" }); + rows.push(...submodules.map((submodule, index) => ({ + type: "submodule" as const, + key: `submodule:${submodule.path}`, + submodule, + index, + }))); + } - {/* Conflicts section - shown during merge/rebase */} - {(mergeInProgress || rebaseInProgress || cherryPickInProgress) && conflictedFiles.length > 0 && ( -
-
- - {t("staging.conflicts")} {"\u00B7"} {t("fileCount", {ns: "common", count: conflictedFiles.length})} - -
- {conflictedFiles.map((f, index) => { - const rowStripe = striped(index); - return ( -
-
onFileSelect(f.path, false)} - onDoubleClick={() => onOpenMergeTool(f.path)} - > - C - {f.path} - {f.conflictType.replace(/_/g, " ")} -
e.stopPropagation()}> - - - - -
-
-
- ); - })} -
- )} + if ((mergeInProgress || rebaseInProgress || cherryPickInProgress) && conflictedFiles.length > 0) { + rows.push({ type: "section", key: "section:conflicts", section: "conflicts" }); + rows.push(...conflictedFiles.map((file, index) => ({ + type: "conflict" as const, + key: `conflict:${file.path}`, + file, + index, + }))); + } + + rows.push({ type: "section", key: "section:staged", section: "staged" }); + if (mergedStaged.length === 0) { + rows.push({ type: "empty", key: "empty:staged", section: "staged" }); + } else { + rows.push(...stagedTreeRows.map(row => ({ + type: "tree" as const, + key: row.type === "directory" ? `staged:directory:${row.node.path}` : `staged:file:${row.node.path}`, + section: "staged" as const, + row, + }))); + } - {/* Staged section */} -
+ rows.push({ type: "section", key: "section:unstaged", section: "unstaged" }); + if (allUnstaged.length === 0) { + rows.push({ type: "empty", key: "empty:unstaged", section: "unstaged" }); + } else { + rows.push(...unstagedTreeRows.map(row => ({ + type: "tree" as const, + key: row.type === "directory" ? `unstaged:directory:${row.node.path}` : `unstaged:file:${row.node.path}`, + section: "unstaged" as const, + row, + }))); + } + + return rows; + }, [ + allUnstaged.length, + cherryPickInProgress, + conflictedFiles, + mergeInProgress, + mergedStaged.length, + rebaseInProgress, + stagedTreeRows, + submodules, + unstagedTreeRows, + ]); + + const renderSectionHeader = (section: StagingListRow & { type: "section" }) => { + if (section.section === "submodules") { + return ( +
- {t("staging.staged")} {"\u00B7"} {t("fileCount", {ns: "common", count: stagedFiles.length})} + {t("staging.submodules")} {"\u00B7"} {submodules.length} - {stagedFiles.length > 0 && ( -
- - -
- )}
- {mergedStaged.length === 0 ? ( -
{t("staging.noStagedChanges")}
- ) : ( - stagedTreeRows.map(row => renderTreeRow(row, "staged")) - )}
+ ); + } - {/* Unstaged section */} -
+ if (section.section === "conflicts") { + return ( +
- - {t("staging.unstaged")} {"\u00B7"} {t("fileCount", {ns: "common", count: allUnstaged.length})} + + {t("staging.conflicts")} {"\u00B7"} {t("fileCount", {ns: "common", count: conflictedFiles.length})} - {allUnstaged.length > 0 && ( -
- - - - -
- )}
- {allUnstaged.length === 0 ? ( -
{t("staging.workingTreeClean")}
- ) : ( - unstagedTreeRows.map(row => renderTreeRow(row, "unstaged")) +
+ ); + } + + const isStaged = section.section === "staged"; + const count = isStaged ? stagedFiles.length : allUnstaged.length; + const selectedPaths = isStaged ? selectedStagedPaths : selectedUnstagedPaths; + + return ( +
+
+ + {t(isStaged ? "staging.staged" : "staging.unstaged")} {"\u00B7"} {t("fileCount", {ns: "common", count})} + + {count > 0 && ( +
+ {isStaged ? ( + <> + + + + ) : ( + <> + + + + + + )} +
)}
+ ); + }; + + const renderConflictRow = (file: ConflictFileItem, index: number) => { + const rowStripe = striped(index); + return ( +
+
+
onFileSelect(file.path, false)} + onDoubleClick={() => onOpenMergeTool(file.path)} + > + C + {file.path} + {file.conflictType.replace(/_/g, " ")} +
e.stopPropagation()}> + + + + +
+
+
+
+ ); + }; + + const renderStagingRow = (_index: number, row: StagingListRow) => { + switch (row.type) { + case "section": + return renderSectionHeader(row); + case "submodule": + return ( +
+
+ onSubmoduleSelect(row.submodule.path)} + onInit={() => onSubmoduleInit(row.submodule.path)} + onUpdate={() => onSubmoduleUpdate(row.submodule.path)} + onSync={() => onSubmoduleSync(row.submodule.path)} + onFetch={() => onSubmoduleFetch(row.submodule.path)} + onPull={() => onSubmodulePull(row.submodule.path)} + onOpen={() => onSubmoduleOpen(row.submodule.path)} + /> +
+
+ ); + case "conflict": + return renderConflictRow(row.file, row.index); + case "tree": + return
{renderTreeRow(row.row, row.section)}
; + case "empty": + return ( +
+
+ {t(row.section === "staged" ? "staging.noStagedChanges" : "staging.workingTreeClean")} +
+
+ ); + } + }; + + return ( +
+ {inlineOperation && !inlineOperationIsCommit && } +
+ row.key} + itemContent={renderStagingRow} + /> +
+ {inlineOperation && inlineOperationIsCommit && } ({ + ask: vi.fn(), + message: vi.fn(), + emit: vi.fn(), + getSshAllowedSignerStatus: vi.fn(), + addSshSigningKeyToAllowedSigners: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-dialog", () => ({ + ask: mocks.ask, + message: mocks.message, +})); + +vi.mock("@tauri-apps/api/event", () => ({ + emit: mocks.emit, +})); + +vi.mock("../../api/commands", () => ({ + getSshAllowedSignerStatus: mocks.getSshAllowedSignerStatus, + addSshSigningKeyToAllowedSigners: mocks.addSshSigningKeyToAllowedSigners, +})); + +const identity: GitIdentity = { + name: "Gitmun Test", + email: "test@gitmun.test", + signingKey: "key::ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey test@gitmun.test", + signingFormat: "ssh", + sshKeyPath: null, + commitSigningEnabled: true, +}; + +function renderPanel(options: { + repoPath?: string | null; + onSaveLocalIdentity?: (payload: Partial) => Promise; + onRefreshLocalIdentity?: () => Promise; +} = {}) { + return render( + {})} + onRefreshLocalIdentity={options.onRefreshLocalIdentity ?? vi.fn(async () => {})} + />, + ); +} + +async function saveLocalIdentity() { + fireEvent.click(screen.getByRole("button", {name: /Edit/i})); + fireEvent.click(screen.getByRole("button", {name: "Save"})); + await waitFor(() => expect(screen.queryByRole("button", {name: "Save"})).not.toBeInTheDocument()); +} + +describe("IdentityPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getSshAllowedSignerStatus.mockResolvedValue({ + setupNeeded: false, + targetPath: null, + blockingReason: null, + allowedSignersConfigured: false, + allowedSignersExists: false, + signingKeyPresent: true, + signingKeyTrusted: true, + resolvedPublicKeyFingerprint: null, + reason: "trusted", + }); + mocks.addSshSigningKeyToAllowedSigners.mockResolvedValue({ + message: "Added", + backendUsed: "git-cli", + }); + mocks.ask.mockResolvedValue(false); + mocks.message.mockResolvedValue("Ok"); + mocks.emit.mockResolvedValue(undefined); + }); + + it("prompts to add SSH signing keys to allowed signers after save", async () => { + const onRefreshLocalIdentity = vi.fn(async () => {}); + mocks.getSshAllowedSignerStatus.mockResolvedValue({ + setupNeeded: true, + targetPath: "C:\\repo\\.git\\gitmun_allowed_signers", + blockingReason: null, + allowedSignersConfigured: false, + allowedSignersExists: false, + signingKeyPresent: true, + signingKeyTrusted: false, + resolvedPublicKeyFingerprint: null, + reason: "untrustedSigningKey", + }); + mocks.ask.mockResolvedValue(true); + renderPanel({onRefreshLocalIdentity}); + + await saveLocalIdentity(); + + await waitFor(() => { + expect(mocks.ask).toHaveBeenCalledWith( + expect.stringContaining("C:\\repo\\.git\\gitmun_allowed_signers"), + expect.objectContaining({okLabel: "Add to allowed signers", cancelLabel: "Not now"}), + ); + expect(mocks.addSshSigningKeyToAllowedSigners).toHaveBeenCalledWith("C:\\repo", "Local"); + expect(onRefreshLocalIdentity).toHaveBeenCalled(); + expect(mocks.emit).toHaveBeenCalledWith("signature-settings-updated"); + }); + }); + + it("does not prompt when allowed signers already contain the key", async () => { + renderPanel(); + + await saveLocalIdentity(); + + expect(mocks.ask).not.toHaveBeenCalled(); + expect(mocks.addSshSigningKeyToAllowedSigners).not.toHaveBeenCalled(); + }); + + it("shows allowed signers path and file state", async () => { + mocks.getSshAllowedSignerStatus.mockResolvedValue({ + setupNeeded: true, + targetPath: "C:\\repo\\.git\\gitmun_allowed_signers", + blockingReason: null, + allowedSignersConfigured: true, + allowedSignersExists: false, + signingKeyPresent: true, + signingKeyTrusted: false, + resolvedPublicKeyFingerprint: null, + reason: "missingAllowedSignersFile", + }); + + renderPanel(); + + expect(await screen.findByText("C:\\repo\\.git\\gitmun_allowed_signers")).toBeInTheDocument(); + expect(screen.getByText("Configured")).toBeInTheDocument(); + expect(screen.getByText("Exists")).toBeInTheDocument(); + expect(screen.getByText("Key trusted")).toBeInTheDocument(); + expect(screen.getByRole("button", {name: "Add to allowed signers"})).toBeInTheDocument(); + }); + + it("adds allowed signer from the status section", async () => { + mocks.getSshAllowedSignerStatus.mockResolvedValue({ + setupNeeded: true, + targetPath: "C:\\repo\\.git\\gitmun_allowed_signers", + blockingReason: null, + allowedSignersConfigured: false, + allowedSignersExists: false, + signingKeyPresent: true, + signingKeyTrusted: false, + resolvedPublicKeyFingerprint: null, + reason: "untrustedSigningKey", + }); + const onRefreshLocalIdentity = vi.fn(async () => {}); + renderPanel({onRefreshLocalIdentity}); + + fireEvent.click(await screen.findByRole("button", {name: "Add to allowed signers"})); + + await waitFor(() => { + expect(mocks.addSshSigningKeyToAllowedSigners).toHaveBeenCalledWith("C:\\repo", "Local"); + expect(onRefreshLocalIdentity).toHaveBeenCalled(); + expect(mocks.emit).toHaveBeenCalledWith("signature-settings-updated"); + }); + }); + + it("does not update settings when the user cancels the prompt", async () => { + mocks.getSshAllowedSignerStatus.mockResolvedValue({ + setupNeeded: true, + targetPath: "C:\\repo\\.git\\gitmun_allowed_signers", + blockingReason: null, + allowedSignersConfigured: false, + allowedSignersExists: false, + signingKeyPresent: true, + signingKeyTrusted: false, + resolvedPublicKeyFingerprint: null, + reason: "untrustedSigningKey", + }); + mocks.ask.mockResolvedValue(false); + renderPanel(); + + await saveLocalIdentity(); + + expect(mocks.ask).toHaveBeenCalled(); + expect(mocks.addSshSigningKeyToAllowedSigners).not.toHaveBeenCalled(); + }); + + it("shows blocking status messages", async () => { + mocks.getSshAllowedSignerStatus.mockResolvedValue({ + setupNeeded: false, + targetPath: null, + blockingReason: "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_MISSING_EMAIL", + allowedSignersConfigured: false, + allowedSignersExists: false, + signingKeyPresent: true, + signingKeyTrusted: false, + resolvedPublicKeyFingerprint: null, + reason: "missingEmail", + }); + renderPanel(); + + await saveLocalIdentity(); + + expect(mocks.message).toHaveBeenCalledWith( + "Configure user.email before adding an allowed signer.", + expect.objectContaining({kind: "error"}), + ); + }); +}); diff --git a/src/components/identity/IdentityPanel.tsx b/src/components/identity/IdentityPanel.tsx index c46f2bd..f42f210 100644 --- a/src/components/identity/IdentityPanel.tsx +++ b/src/components/identity/IdentityPanel.tsx @@ -1,43 +1,72 @@ import React, { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { emit } from "@tauri-apps/api/event"; +import { ask, message } from "@tauri-apps/plugin-dialog"; import { UserIcon, FolderIcon, GlobeIcon, SwapIcon, ShieldIcon, CloseIcon, EditIcon, } from "../icons"; -import type { GitIdentity } from "../../types"; +import { + addSshSigningKeyToAllowedSigners, + getSshAllowedSignerStatus, +} from "../../api/commands"; +import type { GitIdentity, SshAllowedSignerStatus } from "../../types"; import "./IdentityPanel.css"; type IdentityTab = "local" | "global" | "profiles"; const PROFILES_ENABLED = false; +const ALLOWED_SIGNERS_ERROR_CODES = [ + "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_HOME_UNAVAILABLE", + "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_MISSING_EMAIL", + "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_NO_TARGET", + "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_SIGNING_KEY_MISSING", + "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_SIGNING_KEY_UNRESOLVED", +] as const; + +type AllowedSignersErrorCode = typeof ALLOWED_SIGNERS_ERROR_CODES[number]; + +function extractAllowedSignersErrorCode(value: unknown): AllowedSignersErrorCode | null { + const message = String(value); + return ALLOWED_SIGNERS_ERROR_CODES.find(code => message.includes(code)) ?? null; +} + type IdentityPanelProps = { open: boolean; onClose: () => void; + repoPath: string | null; localIdentity: GitIdentity | null; globalIdentity: GitIdentity | null; localIdentitySaving?: boolean; globalIdentitySaving?: boolean; onSaveLocalIdentity?: (payload: Partial) => Promise; onSaveGlobalIdentity?: (payload: Partial) => Promise; + onRefreshLocalIdentity?: () => Promise; + onRefreshGlobalIdentity?: () => Promise; onScopeChange?: (scope: "local" | "global") => void; }; export function IdentityPanel({ open, onClose, + repoPath, localIdentity, globalIdentity, localIdentitySaving = false, globalIdentitySaving = false, onSaveLocalIdentity, onSaveGlobalIdentity, + onRefreshLocalIdentity, + onRefreshGlobalIdentity, onScopeChange, }: IdentityPanelProps) { const { t } = useTranslation("identity"); const [tab, setTab] = useState("local"); const [editMode, setEditMode] = useState(false); const [editFormData, setEditFormData] = useState>({}); + const [allowedSignerStatus, setAllowedSignerStatus] = useState(null); + const [allowedSignerLoading, setAllowedSignerLoading] = useState(false); const didAutoSelectTabRef = useRef(false); const identity = tab === "local" ? localIdentity : globalIdentity; @@ -51,6 +80,35 @@ export function IdentityPanel({ sshKeyPath: null, commitSigningEnabled: false, }; + const activeScope = tab === "local" ? "Local" : tab === "global" ? "Global" : null; + const allowedSignersErrorMessage = React.useCallback((value: unknown) => { + const code = extractAllowedSignersErrorCode(value); + return code ? t(`allowedSigners.errors.${code}`) : String(value); + }, [t]); + + const refreshAllowedSignerStatus = React.useCallback(async () => { + if (!open || !repoPath || !activeScope || displayIdentity.signingFormat !== "ssh") { + setAllowedSignerStatus(null); + return null; + } + + setAllowedSignerLoading(true); + try { + const status = await getSshAllowedSignerStatus(repoPath, activeScope); + setAllowedSignerStatus(status); + return status; + } finally { + setAllowedSignerLoading(false); + } + }, [activeScope, displayIdentity.signingFormat, open, repoPath]); + + const addAllowedSigner = React.useCallback(async () => { + if (!repoPath || !activeScope) return; + await addSshSigningKeyToAllowedSigners(repoPath, activeScope); + await (activeScope === "Local" ? onRefreshLocalIdentity?.() : onRefreshGlobalIdentity?.()); + await refreshAllowedSignerStatus(); + await emit("signature-settings-updated"); + }, [activeScope, onRefreshGlobalIdentity, onRefreshLocalIdentity, refreshAllowedSignerStatus, repoPath]); React.useEffect(() => { if (!open) return; @@ -93,6 +151,30 @@ export function IdentityPanel({ } }, [tab]); + React.useEffect(() => { + let cancelled = false; + + async function load() { + if (!open || !repoPath || !activeScope || displayIdentity.signingFormat !== "ssh") { + if (!cancelled) setAllowedSignerStatus(null); + return; + } + + setAllowedSignerLoading(true); + try { + const status = await getSshAllowedSignerStatus(repoPath, activeScope); + if (!cancelled) setAllowedSignerStatus(status); + } finally { + if (!cancelled) setAllowedSignerLoading(false); + } + } + + load(); + return () => { + cancelled = true; + }; + }, [activeScope, displayIdentity.signingFormat, displayIdentity.signingKey, displayIdentity.sshKeyPath, open, repoPath]); + // Initialise edit form when entering edit mode React.useEffect(() => { if (!open) return; @@ -117,9 +199,36 @@ export function IdentityPanel({ try { await onSave(editFormData); setEditMode(false); + if (!repoPath || !activeScope) return; + + const status = await getSshAllowedSignerStatus(repoPath, activeScope); + setAllowedSignerStatus(status); + if (status.blockingReason) { + await message(allowedSignersErrorMessage(status.blockingReason), { + title: t("allowedSigners.setupBlockedTitle"), + kind: "error", + }); + return; + } + if (!status.setupNeeded) return; + + const confirmed = await ask( + t("allowedSigners.prompt", {path: status.targetPath}), + { + title: t("allowedSigners.promptTitle"), + kind: "info", + okLabel: t("allowedSigners.add"), + cancelLabel: t("allowedSigners.notNow"), + }, + ); + if (!confirmed) return; + + await addAllowedSigner(); } catch (e) { - // Error is handled by the hook - console.error(e); + await message(allowedSignersErrorMessage(e), { + title: t("allowedSigners.setupFailedTitle"), + kind: "error", + }); } }; @@ -196,7 +305,7 @@ export function IdentityPanel({ {displayIdentity.commitSigningEnabled ? t("labels.enabled") : t("labels.disabled")}
- {displayIdentity.signingKey && ( + {(displayIdentity.signingKey || displayIdentity.signingFormat === "ssh") && (
{t("labels.format")} @@ -204,16 +313,50 @@ export function IdentityPanel({ {displayIdentity.signingFormat === "ssh" ? "SSH" : "GPG"}
-
- {displayIdentity.signingFormat === "ssh" ? t("labels.key") : t("labels.keyId")} - {displayIdentity.signingKey} -
+ {displayIdentity.signingKey && ( +
+ {displayIdentity.signingFormat === "ssh" ? t("labels.key") : t("labels.keyId")} + {displayIdentity.signingKey} +
+ )} {displayIdentity.sshKeyPath && (
{t("labels.file")} {displayIdentity.sshKeyPath}
)} + {displayIdentity.signingFormat === "ssh" && ( +
+
+ {t("allowedSigners.configured")} + {allowedSignerStatus?.allowedSignersConfigured ? t("allowedSigners.yes") : t("allowedSigners.no")} +
+
+ {t("allowedSigners.exists")} + {allowedSignerStatus?.allowedSignersExists ? t("allowedSigners.yes") : t("allowedSigners.no")} +
+ {allowedSignerStatus?.targetPath && ( +
+ {t("allowedSigners.path")} + {allowedSignerStatus.targetPath} +
+ )} +
+ {t("allowedSigners.keyTrusted")} + {allowedSignerStatus?.signingKeyTrusted ? t("allowedSigners.yes") : t("allowedSigners.no")} +
+ {allowedSignerStatus?.setupNeeded && ( + + )} +
+ )}
)}
diff --git a/src/components/update/UpdateDialog.tsx b/src/components/update/UpdateDialog.tsx index 016d185..de94d55 100644 --- a/src/components/update/UpdateDialog.tsx +++ b/src/components/update/UpdateDialog.tsx @@ -59,36 +59,27 @@ export function UpdateDialog({ ? Math.max(0, Math.min(100, Math.round((downloadedBytes / contentLength) * 100))) : null; const isMicrosoftStore = update.source === "microsoftStore"; - const isBusy = phase === "downloading" || phase === "installing" || phase === "storeOpening"; + const isBusy = phase === "downloading" || phase === "installing"; const isSuccess = phase === "success"; let title = isMicrosoftStore ? t("labels.storePromptTitle") : t("labels.promptTitle", { version: update.version }); let body = isMicrosoftStore ? t("labels.storePromptBody") : t("labels.promptBody"); - if (phase === "storeOpening") { - title = t("labels.storeOpeningTitle"); - body = t("labels.storeOpeningBody"); - } else if (phase === "storeDeferred") { - title = t("labels.storeDeferredTitle"); - body = t("labels.storeDeferredBody"); + if (phase === "storeOpened") { + title = t("labels.storeOpenedTitle"); + body = t("labels.storeOpenedBody"); } else if (phase === "storeError") { title = t("labels.storeFailedTitle"); body = t("labels.storeFailedBody", { message: errorMessage ?? t("labels.unknownError") }); - } else if (phase === "downloading") { - title = isMicrosoftStore - ? t("labels.storeDownloadingTitle") - : t("labels.downloadingTitle", { version: update.version }); - body = isMicrosoftStore - ? t("labels.storeDownloadingBody") - : hasKnownLength - ? t("labels.downloadedOf", { downloaded: formatBytes(downloadedBytes), total: formatBytes(contentLength) }) - : t("labels.downloaded", { bytes: formatBytes(downloadedBytes) }); - } else if (phase === "installing") { - title = isMicrosoftStore - ? t("labels.storeInstallingTitle") - : t("labels.installingTitle", { version: update.version }); - body = isMicrosoftStore ? t("labels.storeInstallingBody") : t("labels.installingBody"); + } else if (!isMicrosoftStore && phase === "downloading") { + title = t("labels.downloadingTitle", { version: update.version }); + body = hasKnownLength + ? t("labels.downloadedOf", { downloaded: formatBytes(downloadedBytes), total: formatBytes(contentLength) }) + : t("labels.downloaded", { bytes: formatBytes(downloadedBytes) }); + } else if (!isMicrosoftStore && phase === "installing") { + title = t("labels.installingTitle", { version: update.version }); + body = t("labels.installingBody"); } else if (isSuccess && !isMicrosoftStore) { title = t("labels.installedTitle", { version: update.version }); body = t("labels.installedBody"); @@ -118,7 +109,7 @@ export function UpdateDialog({ {errorMessage && !isMicrosoftStore && (
{errorMessage}
)} - {(phase === "downloading" || phase === "installing" || phase === "storeOpening") && ( + {(phase === "downloading" || phase === "installing") && (
- {phase === "storeOpening" - ? t("labels.storeOpening") - : phase === "installing" - ? t("labels.installing") - : percent == null ? t("labels.downloading") : `${percent}%`} + {phase === "installing" + ? t("labels.installing") + : percent == null ? t("labels.downloading") : `${percent}%`}
)} @@ -155,11 +144,11 @@ export function UpdateDialog({ {t("actions.later")} )} - {(isSuccess || phase === "storeDeferred" || phase === "storeError") && ( + {(isSuccess || phase === "storeOpened" || phase === "storeError") && ( diff --git a/src/hooks/useGitIdentity.ts b/src/hooks/useGitIdentity.ts index c4cb8c6..5becd54 100644 --- a/src/hooks/useGitIdentity.ts +++ b/src/hooks/useGitIdentity.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { getIdentity, setIdentity as setIdentityApi } from "../api/commands"; import type { GitIdentity, IdentityScope, SetIdentityRequest } from "../types"; @@ -8,20 +8,48 @@ export function useGitIdentity(repoPath: string | null, scope: IdentityScope) { const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - useEffect(() => { + const refreshIdentity = useCallback(async () => { if (!repoPath) { setIdentity(null); return; } - let cancelled = false; setLoading(true); setError(null); - getIdentity(repoPath, scope) - .then(id => { if (!cancelled) setIdentity(id); }) - .catch(e => { if (!cancelled) setError(String(e)); }) - .finally(() => { if (!cancelled) setLoading(false); }); + try { + const id = await getIdentity(repoPath, scope); + setIdentity(id); + } catch (e) { + setError(String(e)); + } finally { + setLoading(false); + } + }, [repoPath, scope]); + + useEffect(() => { + let cancelled = false; + + const load = async () => { + if (!repoPath) { + setIdentity(null); + return; + } + + setLoading(true); + setError(null); + + try { + const id = await getIdentity(repoPath, scope); + if (!cancelled) setIdentity(id); + } catch (e) { + if (!cancelled) setError(String(e)); + } finally { + if (!cancelled) setLoading(false); + } + }; + + void load(); return () => { cancelled = true; }; }, [repoPath, scope]); @@ -42,5 +70,5 @@ export function useGitIdentity(repoPath: string | null, scope: IdentityScope) { } }; - return { identity, loading, saving, error, saveIdentity }; + return { identity, loading, saving, error, saveIdentity, refreshIdentity }; } diff --git a/src/hooks/useUpdateFlow.test.ts b/src/hooks/useUpdateFlow.test.ts index 14dbfeb..d488ecf 100644 --- a/src/hooks/useUpdateFlow.test.ts +++ b/src/hooks/useUpdateFlow.test.ts @@ -8,20 +8,19 @@ vi.mock("../api/commands", () => ({ getAppUpdateChannel: vi.fn(), checkMicrosoftStoreUpdate: vi.fn(), checkForAppUpdate: vi.fn(), - requestMicrosoftStoreUpdate: vi.fn(), - requestMicrosoftStoreUpdateWithProgress: vi.fn(), + openMicrosoftStoreUpdatePage: vi.fn(), downloadAndInstallAppUpdateWithProgress: vi.fn(), })); import { checkMicrosoftStoreUpdate, getAppUpdateChannel, - requestMicrosoftStoreUpdateWithProgress, + openMicrosoftStoreUpdatePage, } from "../api/commands"; const mockGetAppUpdateChannel = vi.mocked(getAppUpdateChannel); const mockCheckMicrosoftStoreUpdate = vi.mocked(checkMicrosoftStoreUpdate); -const mockRequestMicrosoftStoreUpdateWithProgress = vi.mocked(requestMicrosoftStoreUpdateWithProgress); +const mockOpenMicrosoftStoreUpdatePage = vi.mocked(openMicrosoftStoreUpdatePage); const storage = new Map(); @@ -46,7 +45,6 @@ describe("useUpdateFlow", () => { currentVersion: "1.0.0", packageCount: 1, mandatory: false, - queueStatus: null, }; test("checks Microsoft Store updates on each launch check", async () => { @@ -65,49 +63,8 @@ describe("useUpdateFlow", () => { }); }); - test("shows Microsoft Store download progress below install threshold", async () => { - mockRequestMicrosoftStoreUpdateWithProgress.mockImplementation(async (onProgress) => { - onProgress({ - event: "Progress", - data: { - packageDownloadProgress: 0.5, - totalDownloadProgress: 0.4, - packageBytesDownloaded: 400, - packageDownloadSizeInBytes: 1000, - packageUpdateState: "Unknown", - }, - }); - return {status: "Unknown", queueStatus: {state: "Active", extendedState: "ActiveDownloading", progress: null}}; - }); - - const { result } = renderHook(() => useUpdateFlow()); - - act(() => { - result.current.showUpdatePrompt(storeUpdate); - }); - await act(async () => { - await result.current.installUpdate(); - }); - - expect(result.current.dialog.phase).toBe("downloading"); - expect(result.current.dialog.downloadedBytes).toBe(400); - expect(result.current.dialog.contentLength).toBe(1000); - }); - - test("shows Microsoft Store install progress at install threshold", async () => { - mockRequestMicrosoftStoreUpdateWithProgress.mockImplementation(async (onProgress) => { - onProgress({ - event: "Progress", - data: { - packageDownloadProgress: 1, - totalDownloadProgress: 0.85, - packageBytesDownloaded: 1000, - packageDownloadSizeInBytes: 1000, - packageUpdateState: "Unknown", - }, - }); - return {status: "Unknown", queueStatus: {state: "Active", extendedState: "ActiveInstalling", progress: null}}; - }); + test("opens Microsoft Store page from update prompt", async () => { + mockOpenMicrosoftStoreUpdatePage.mockResolvedValue(); const { result } = renderHook(() => useUpdateFlow()); @@ -118,32 +75,15 @@ describe("useUpdateFlow", () => { await result.current.installUpdate(); }); - expect(result.current.dialog.phase).toBe("installing"); - expect(result.current.dialog.downloadedBytes).toBe(850); - expect(result.current.dialog.contentLength).toBe(1000); + expect(mockOpenMicrosoftStoreUpdatePage).toHaveBeenCalledTimes(1); + expect(result.current.dialog.phase).toBe("storeOpened"); + expect(result.current.dialog.phase).not.toBe("downloading"); + expect(result.current.dialog.phase).not.toBe("installing"); + expect(result.current.statusMessage).toBe("Microsoft Store opened."); }); - test("maps Microsoft Store cancellation to deferred", async () => { - mockRequestMicrosoftStoreUpdateWithProgress.mockResolvedValue({status: "Canceled", queueStatus: null}); - - const { result } = renderHook(() => useUpdateFlow()); - - act(() => { - result.current.showUpdatePrompt(storeUpdate); - }); - await act(async () => { - await result.current.installUpdate(); - }); - - expect(result.current.dialog.phase).toBe("storeDeferred"); - expect(result.current.statusMessage).toBe("Microsoft Store update deferred."); - }); - - test("maps Microsoft Store queue errors to Store error", async () => { - mockRequestMicrosoftStoreUpdateWithProgress.mockResolvedValue({ - status: "OtherError", - queueStatus: {state: "Error", extendedState: "PausedLowBattery", progress: null}, - }); + test("maps Microsoft Store open failure to Store error", async () => { + mockOpenMicrosoftStoreUpdatePage.mockRejectedValue(new Error("Store unavailable")); const { result } = renderHook(() => useUpdateFlow()); @@ -155,7 +95,7 @@ describe("useUpdateFlow", () => { }); expect(result.current.dialog.phase).toBe("storeError"); - expect(result.current.dialog.errorMessage).toBe("PausedLowBattery"); - expect(result.current.statusMessage).toBe("Microsoft Store update failed: PausedLowBattery"); + expect(result.current.dialog.errorMessage).toBe("Store unavailable"); + expect(result.current.statusMessage).toBe("Microsoft Store update failed: Store unavailable"); }); }); diff --git a/src/hooks/useUpdateFlow.ts b/src/hooks/useUpdateFlow.ts index e9d3b4c..762663c 100644 --- a/src/hooks/useUpdateFlow.ts +++ b/src/hooks/useUpdateFlow.ts @@ -4,23 +4,19 @@ import * as api from "../api/commands"; import type { AppAvailableUpdate, AvailableUpdate, - MicrosoftStoreQueueStatus, MicrosoftStoreUpdate, - MicrosoftStoreUpdateEvent, - MicrosoftStoreUpdateProgress, - MicrosoftStoreUpdateStatus, UpdateDownloadEvent, } from "../types"; const DISMISSED_UPDATE_VERSION_KEY = "gitmun.dismissedUpdateVersion"; +const STORE_OPEN_FAILED = "GITMUN_ERROR_MICROSOFT_STORE_OPEN_FAILED"; export type UpdateDialogPhase = | "prompt" | "downloading" | "installing" | "success" - | "storeOpening" - | "storeDeferred" + | "storeOpened" | "storeError"; export type UpdateDialogState = { @@ -80,15 +76,12 @@ function createClosedState(): UpdateDialogState { }; } -function isStoreErrorStatus(status: MicrosoftStoreUpdateStatus): boolean { - return status !== "Completed" && status !== "Canceled" && status !== "Unknown"; -} - -function storeProgressPosition(progress: MicrosoftStoreUpdateProgress): number { - const totalProgress = Number.isFinite(progress.totalDownloadProgress) - ? progress.totalDownloadProgress - : 0; - return Math.max(0, Math.min(1, totalProgress)); +function localiseUpdateError(error: unknown, t: (key: string, options?: Record) => string): string { + const message = error instanceof Error ? error.message : String(error); + if (message.includes(STORE_OPEN_FAILED)) { + return t("errors.microsoftStoreOpenFailed"); + } + return message; } export function useUpdateFlow() { @@ -98,24 +91,14 @@ export function useUpdateFlow() { const [dialog, setDialog] = useState(createClosedState); const openPrompt = useCallback((update: AppAvailableUpdate) => { - const storeQueueStatus = update.source === "microsoftStore" ? update.queueStatus : null; - const storeProgress = storeQueueStatus?.progress ?? null; - const storeProgressPosition = storeProgress ? Math.max(0, Math.min(1, storeProgress.totalDownloadProgress)) : 0; - const phase: UpdateDialogPhase = storeQueueStatus?.state === "Canceled" - ? "storeDeferred" - : storeQueueStatus?.state === "Error" - ? "storeError" - : storeQueueStatus?.state === "Active" || storeQueueStatus?.state === "Paused" - ? storeProgressPosition >= 0.8 ? "installing" : "downloading" - : "prompt"; setDialog({ open: true, update, - phase, - errorMessage: storeQueueStatus?.state === "Error" ? storeQueueStatus.extendedState : null, + phase: "prompt", + errorMessage: null, dontShowAgain: false, - downloadedBytes: Math.round(storeProgressPosition * 1000), - contentLength: storeProgress ? 1000 : null, + downloadedBytes: 0, + contentLength: null, }); }, []); @@ -157,19 +140,7 @@ export function useUpdateFlow() { return update; } - if (update.source === "microsoftStore" && update.queueStatus?.state === "Completed") { - setDialog(createClosedState()); - setStatusMessage(t("status.storeCompleted")); - return update; - } - showUpdatePrompt(update); - if ( - update.source === "microsoftStore" - && (update.queueStatus?.state === "Active" || update.queueStatus?.state === "Paused") - ) { - setStatusMessage(t("status.storeInProgress")); - } return update; } catch (error) { const message = t("status.checkFailed", { message: String(error) }); @@ -217,82 +188,6 @@ export function useUpdateFlow() { }); }, []); - const applyMicrosoftStoreProgress = useCallback((progress: MicrosoftStoreUpdateProgress) => { - setDialog((current) => { - if (!current.update) { - return current; - } - if (progress.packageUpdateState === "Canceled") { - return { - ...current, - phase: "storeDeferred", - errorMessage: null, - }; - } - if (isStoreErrorStatus(progress.packageUpdateState)) { - return { - ...current, - phase: "storeError", - errorMessage: progress.packageUpdateState, - }; - } - const totalProgress = storeProgressPosition(progress); - return { - ...current, - phase: totalProgress >= 0.8 ? "installing" : "downloading", - errorMessage: null, - downloadedBytes: Math.round(totalProgress * 1000), - contentLength: 1000, - }; - }); - }, []); - - const applyMicrosoftStoreQueueStatus = useCallback((queueStatus: MicrosoftStoreQueueStatus) => { - if (queueStatus.progress) { - applyMicrosoftStoreProgress(queueStatus.progress); - } - setDialog((current) => { - if (!current.update) { - return current; - } - if (queueStatus.state === "Completed") { - return createClosedState(); - } - if (queueStatus.state === "Canceled") { - return { - ...current, - phase: "storeDeferred", - errorMessage: null, - }; - } - if (queueStatus.state === "Error") { - return { - ...current, - phase: "storeError", - errorMessage: queueStatus.extendedState, - }; - } - if (queueStatus.state === "Active" || queueStatus.state === "Paused") { - return { - ...current, - phase: queueStatus.progress - ? storeProgressPosition(queueStatus.progress) >= 0.8 ? "installing" : "downloading" - : current.phase === "storeOpening" ? "downloading" : current.phase, - errorMessage: null, - }; - } - return current; - }); - }, [applyMicrosoftStoreProgress]); - - const handleMicrosoftStoreEvent = useCallback((event: MicrosoftStoreUpdateEvent) => { - if (event.event === "Progress") { - applyMicrosoftStoreProgress(event.data); - return; - } - applyMicrosoftStoreQueueStatus(event.data); - }, [applyMicrosoftStoreProgress, applyMicrosoftStoreQueueStatus]); - const installUpdate = useCallback(async () => { const update = dialog.update; if (!update) { @@ -300,39 +195,16 @@ export function useUpdateFlow() { } if (update.source === "microsoftStore") { - setDialog((current) => current.update ? { - ...current, - phase: "storeOpening", - errorMessage: null, - downloadedBytes: 0, - contentLength: null, - } : current); - try { - const result = await api.requestMicrosoftStoreUpdateWithProgress(handleMicrosoftStoreEvent); - if (result.queueStatus) { - applyMicrosoftStoreQueueStatus(result.queueStatus); - } - if ( - result.status === "Canceled" - || result.queueStatus?.state === "Active" - || result.queueStatus?.state === "Paused" - ) { - setDialog((current) => current.update ? { - ...current, - phase: result.status === "Canceled" ? "storeDeferred" : current.phase, - errorMessage: null, - } : current); - setStatusMessage(result.status === "Canceled" ? t("status.storeDeferred") : t("status.storeInProgress")); - return; - } - if (result.status !== "Completed") { - throw new Error(result.queueStatus?.extendedState ?? result.status); - } - setDialog(createClosedState()); - setStatusMessage(t("status.storeCompleted")); + await api.openMicrosoftStoreUpdatePage(); + setDialog((current) => current.update ? { + ...current, + phase: "storeOpened", + errorMessage: null, + } : current); + setStatusMessage(t("status.storeOpened")); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = localiseUpdateError(error, t); setDialog((current) => current.update ? { ...current, phase: "storeError", @@ -370,10 +242,8 @@ export function useUpdateFlow() { setStatusMessage(message); } }, [ - applyMicrosoftStoreQueueStatus, dialog.update, handleDownloadEvent, - handleMicrosoftStoreEvent, t, ]); diff --git a/src/i18n/locales/en/centre.json b/src/i18n/locales/en/centre.json index 59105c7..6a42b60 100644 --- a/src/i18n/locales/en/centre.json +++ b/src/i18n/locales/en/centre.json @@ -50,6 +50,7 @@ "committingAndPushing": "Committing and Pushing...", "messageRequired": "Message required to commit", "resizeEditor": "Resize commit message editor", + "restorePreviousMessage": "Restore previous message", "rebaseInProgress": "Rebase in progress", "cherryPickInProgress": "Cherry-pick in progress", "subjectTooLong": "Subject line exceeds {{count}} characters" @@ -79,6 +80,16 @@ }, "log": { "allRefs": "All refs", + "addMySshSigningKey": "Add my SSH signing key", + "addingAllowedSigner": "Adding...", + "allowedSignersRepairFailed": "Allowed signers update failed", + "allowedSignersErrors": { + "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_HOME_UNAVAILABLE": "Could not determine the home directory for the allowed signers file.", + "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_MISSING_EMAIL": "Configure user.email before adding an allowed signer.", + "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_NO_TARGET": "No allowed signers file is available.", + "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_SIGNING_KEY_MISSING": "user.signingkey is not configured.", + "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_SIGNING_KEY_UNRESOLVED": "Could not resolve user.signingkey to an SSH public key." + }, "badSignature": "Bad signature", "cherryPickCommit": "Cherry-pick Commit", "close": "Close", @@ -86,10 +97,14 @@ "commitRefs": "Commit refs", "copyCommitHash": "Copy Commit Hash", "copyDetails": "Copy Details", + "copyFingerprint": "Copy fingerprint", "copyShortHash": "Copy Short Hash", + "copySigner": "Copy signer", "copySubject": "Copy Subject", "createTagHere": "Create Tag Here...", "currentCheckout": "Current checkout", + "exportCommitPatch": "Export Commit Patch...", + "exportCommitPatches": "Export Commit Patches...", "date": "Date", "fingerprint": "Fingerprint", "graphCommit": "Commit {{hash}}", @@ -124,6 +139,8 @@ "signedVerified": "This commit was signed with a verified signature.", "signer": "Signer", "softReset": "Soft Reset to Here", + "sshAllowedSignersMissing": "Your SSH allowed signers file is configured but missing. Add your configured signing key to recreate it.", + "sshAllowedSignersUntrusted": "This SSH signature could not be matched against your allowed signers file.", "tagRef": "Tag {{name}}", "timeDaysAgo_one": "{{count}} day ago", "timeDaysAgo_other": "{{count}} days ago", @@ -133,6 +150,23 @@ "viewNextCommits": "View next {{count}} commits", "verified": "Verified" }, + "operation": { + "commitAndPushMessage": "Gitmun is creating the commit and pushing it to the remote.", + "commitAndPushTitle": "Committing and pushing", + "commitMessage": "Gitmun is writing the commit. This can take a moment for large indexes.", + "commitTitle": "Creating commit", + "stageAllMessage": "Gitmun is staging every working tree change.", + "stageAllTitle": "Staging all changes", + "stageMessage_one": "Gitmun is staging {{count}} file.", + "stageMessage_other": "Gitmun is staging {{count}} files.", + "stageTitle": "Staging changes", + "stillRunningMessage": "This operation is still running. Gitmun will update the view when it finishes.", + "unstageAllMessage": "Gitmun is removing every staged change from the index.", + "unstageAllTitle": "Unstaging all changes", + "unstageMessage_one": "Gitmun is unstaging {{count}} file.", + "unstageMessage_other": "Gitmun is unstaging {{count}} files.", + "unstageTitle": "Unstaging changes" + }, "pushRejected": { "cancel": "Cancel", "currentBranch": "Current branch", @@ -172,6 +206,7 @@ "unstageFile": "Unstage file", "unstageSelected": "Unstage Selected", "unstaged": "Unstaged", + "untrackedDirectory": "Untracked directory", "workingTreeClean": "Working tree clean", "stagedChanges": "Staged Changes", "unstagedChanges": "Unstaged Changes", diff --git a/src/i18n/locales/en/identity.json b/src/i18n/locales/en/identity.json index 18f2d13..5037e72 100644 --- a/src/i18n/locales/en/identity.json +++ b/src/i18n/locales/en/identity.json @@ -31,6 +31,28 @@ "save": "Save", "saving": "Saving..." }, + "allowedSigners": { + "add": "Add to allowed signers", + "adding": "Adding...", + "configured": "Configured", + "exists": "Exists", + "keyTrusted": "Key trusted", + "no": "No", + "notNow": "Not now", + "path": "Path", + "prompt": "Add your configured SSH signing key to {{path}} so Gitmun can verify commits signed with it?", + "promptTitle": "Trust SSH signing key?", + "setupBlockedTitle": "Allowed signers setup unavailable", + "setupFailedTitle": "Allowed signers setup failed", + "yes": "Yes", + "errors": { + "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_HOME_UNAVAILABLE": "Could not determine the home directory for the allowed signers file.", + "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_MISSING_EMAIL": "Configure user.email before adding an allowed signer.", + "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_NO_TARGET": "No allowed signers file is available.", + "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_SIGNING_KEY_MISSING": "user.signingkey is not configured.", + "GITMUN_ERROR_SSH_ALLOWED_SIGNERS_SIGNING_KEY_UNRESOLVED": "Could not resolve user.signingkey to an SSH public key." + } + }, "profiles": { "description": "Switch between saved identity profiles for different contexts.", "comingSoon": "Identity profiles coming soon" diff --git a/src/i18n/locales/en/projectView.json b/src/i18n/locales/en/projectView.json index 5d7136d..2ff4155 100644 --- a/src/i18n/locales/en/projectView.json +++ b/src/i18n/locales/en/projectView.json @@ -11,7 +11,8 @@ "remove": "Remove", "revert": "Revert", "reset": "Reset", - "stashAndSwitch": "Stash and Switch" + "stashAndSwitch": "Stash and Switch", + "tryThreeWayApply": "Try 3-way apply" }, "ask": { "abortCherryPick": { @@ -64,6 +65,10 @@ "message": "Apply patch file to the working tree?", "title": "Import Patch" }, + "importPatchThreeWay": { + "message": "The patch does not apply cleanly:\n\n{{message}}\n\nTry a 3-way apply? This may leave conflicts to resolve in the Changes tab.", + "title": "Patch Does Not Apply Cleanly" + }, "removeRemote": { "message": "Remove remote \"{{remote}}\"? This will also delete all remote-tracking branches for this remote.", "title": "Remove Remote" @@ -230,6 +235,7 @@ "noPatchChanges": "No changes available for patch export", "patchExported": "Exported {{file}}", "patchImported": "Patch imported", + "patchImportedWithConflicts": "Patch applied with conflicts - resolve them in the Changes tab", "pullComplete": "Pull complete", "pushComplete": "Push complete", "pushDetached": "Push is unavailable while HEAD is detached.", @@ -262,6 +268,16 @@ "upstreamRepaired": "Upstream repaired" }, "patch": { + "errors": { + "GITMUN_ERROR_PATCH_EXPORT_COMMIT_HASH_EMPTY": "Select a commit before exporting a patch.", + "GITMUN_ERROR_PATCH_EXPORT_COMMIT_HASH_INVALID": "One selected commit could not be found.", + "GITMUN_ERROR_PATCH_EXPORT_COMMIT_HASH_INVALID_CHARACTER": "One selected commit hash contains an invalid character." + }, + "import": { + "GITMUN_ERROR_PATCH_IMPORT_THREE_WAY_BLOCKED": "Commit, stash, or discard local changes before trying 3-way patch apply.", + "GITMUN_PATCH_IMPORT_APPLIED": "Patch imported", + "GITMUN_PATCH_IMPORT_CONFLICTS": "Patch applied with conflicts - resolve them in the Changes tab" + }, "exportPickerTitle": "Export patch file", "importPickerTitle": "Import patch file", "patchFilesFilter": "Patch files" diff --git a/src/i18n/locales/en/update.json b/src/i18n/locales/en/update.json index f3d19fd..3f29542 100644 --- a/src/i18n/locales/en/update.json +++ b/src/i18n/locales/en/update.json @@ -2,8 +2,11 @@ "actions": { "close": "Close", "later": "Remind me later", - "updateNow": "Update now", - "updateWithMicrosoftStore": "Update with Microsoft Store" + "openMicrosoftStore": "Open Microsoft Store", + "updateNow": "Update now" + }, + "errors": { + "microsoftStoreOpenFailed": "Microsoft Store could not be opened." }, "labels": { "dontShowAgain": "Don't show this update again", @@ -19,19 +22,12 @@ "published": "Published {{date}}", "promptBody": "A newer Gitmun release is ready to download.", "promptTitle": "Update {{version}} is available", - "storeDeferredBody": "The Microsoft Store update was not started.", - "storeDeferredTitle": "Update deferred", - "storeDownloadingBody": "Microsoft Store is downloading the update.", - "storeDownloadingTitle": "Downloading Microsoft Store update", - "storeFailedBody": "Microsoft Store could not start the update: {{message}}", + "storeFailedBody": "Microsoft Store could not be opened: {{message}}", "storeFailedTitle": "Microsoft Store update failed", - "storeInstallingBody": "Microsoft Store is installing the update. Gitmun may close while Windows applies it.", - "storeInstallingTitle": "Installing Microsoft Store update", "storeMandatory": "Microsoft Store marks this update as required.", - "storeOpening": "Starting Microsoft Store update...", - "storeOpeningBody": "Windows is preparing the update. Follow any Windows prompts to continue.", - "storeOpeningTitle": "Starting Microsoft Store update", - "storePromptBody": "A newer Gitmun release is available through Microsoft Store.", + "storeOpenedBody": "Use Microsoft Store to update Gitmun.", + "storeOpenedTitle": "Microsoft Store opened", + "storePromptBody": "A newer Gitmun release is available through Microsoft Store. Open the Store page to update Gitmun.", "storePromptTitle": "An update is available", "unknownError": "Unknown error" }, @@ -42,10 +38,8 @@ "latest": "You're already running the latest version.", "latestMicrosoftStore": "You're already running the latest Microsoft Store version.", "managed": "Updates are managed by this installation channel.", - "storeCompleted": "Microsoft Store finished processing the update.", - "storeDeferred": "Microsoft Store update deferred.", "storeFailed": "Microsoft Store update failed: {{message}}", - "storeInProgress": "Microsoft Store is processing the update.", + "storeOpened": "Microsoft Store opened.", "storeUpdateAvailable": "A Microsoft Store update is available.", "updateAvailable": "Update {{version}} is available." } diff --git a/src/types.ts b/src/types.ts index 94ce849..d475359 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,26 @@ export type AvatarProviderMode = "Off" | "Libravatar"; export type CommitDateMode = "AuthorDate" | "CommitterDate"; export type CommitLogScope = "currentCheckout" | "allRefs"; export type CommitPrimaryAction = "commit" | "commitAndPush"; +export type StagingOperationKind = "stage" | "stageAll" | "unstage" | "unstageAll"; +export type LongRunningOperationKind = StagingOperationKind | CommitPrimaryAction; + +export type StagingOperation = { + kind: StagingOperationKind; + count?: number; +}; + +export type LongRunningOperation = { + id: number; + kind: LongRunningOperationKind; + count?: number; + startedAt: number; +}; + +export type OperationFeedbackContent = { + kind: LongRunningOperationKind; + title: string; + message: string; +}; export type LinuxGraphicsMode = "Auto" | "Safe" | "Native"; export type LinuxTerminalEmulator = | "Auto" @@ -81,7 +101,6 @@ export type Settings = { autoCheckForUpdatesOnLaunch: boolean; autoInstallUpdates: boolean; updateEndpoint: string; - enableUpdateWithMSStoreFlow?: boolean; linuxGraphicsMode: LinuxGraphicsMode; linuxTerminalEmulator: LinuxTerminalEmulator; linuxTerminalCustomCommand: string; @@ -166,53 +185,6 @@ export type MicrosoftStoreUpdate = { currentVersion: string; packageCount: number; mandatory: boolean; - queueStatus: MicrosoftStoreQueueStatus | null; -}; - -export type MicrosoftStoreUpdateStatus = - | "Completed" - | "Canceled" - | "OtherError" - | "ErrorWifiRecommended" - | "ErrorWifiRequired" - | "ErrorLowBattery" - | "Unknown"; - -export type MicrosoftStoreQueueState = - | "Active" - | "Paused" - | "Completed" - | "Canceled" - | "Error" - | "Unknown"; - -export type MicrosoftStoreUpdateProgress = { - packageDownloadProgress: number; - totalDownloadProgress: number; - packageBytesDownloaded: number; - packageDownloadSizeInBytes: number; - packageUpdateState: MicrosoftStoreUpdateStatus; -}; - -export type MicrosoftStoreQueueStatus = { - state: MicrosoftStoreQueueState; - extendedState: string; - progress: MicrosoftStoreUpdateProgress | null; -}; - -export type MicrosoftStoreUpdateEvent = - | { - event: "Progress"; - data: MicrosoftStoreUpdateProgress; - } - | { - event: "QueueStatus"; - data: MicrosoftStoreQueueStatus; - }; - -export type MicrosoftStoreUpdateResult = { - status: MicrosoftStoreUpdateStatus; - queueStatus: MicrosoftStoreQueueStatus | null; }; export type AppAvailableUpdate = @@ -259,6 +231,7 @@ export type RepoRequest = { export type ImportPatchRequest = RepoRequest & { patchPath: string; + threeWay?: boolean; }; export type ExportPatchFileSelection = { @@ -272,11 +245,21 @@ export type ExportPatchRequest = RepoRequest & { files?: ExportPatchFileSelection[]; }; +export type ExportCommitPatchRequest = RepoRequest & { + patchPath: string; + commitHashes: string[]; +}; + export type CommitRequest = RepoRequest & { message: string; amend?: boolean; }; +export type CommitMessageRecovery = { + message: string; + updatedAt: number; +}; + export type CommitHistoryRequest = RepoRequest & { limit?: number; afterHash?: string; @@ -417,6 +400,12 @@ export type FileStatusItem = { status: string; additions: number | null; deletions: number | null; + kind?: "file" | "directory"; +}; + +export type UnversionedItem = { + path: string; + kind: "file" | "directory"; }; export type ConflictFileItem = { @@ -454,6 +443,7 @@ export type RepoStatus = { changedFiles: FileStatusItem[]; stagedFiles: FileStatusItem[]; unversionedFiles: string[]; + unversionedItems?: UnversionedItem[]; submodules: SubmoduleStatus[]; currentBranch: string | null; detachedHead: boolean; @@ -697,6 +687,18 @@ export type GitIdentity = { commitSigningEnabled: boolean; }; +export type SshAllowedSignerStatus = { + setupNeeded: boolean; + targetPath?: string | null; + blockingReason?: string | null; + allowedSignersConfigured: boolean; + allowedSignersExists: boolean; + signingKeyPresent: boolean; + signingKeyTrusted: boolean; + resolvedPublicKeyFingerprint?: string | null; + reason?: "notSsh" | "missingSigningKey" | "missingEmail" | "missingAllowedSignersFile" | "untrustedSigningKey" | "trusted" | "unresolvedSigningKey" | null; +}; + export type SetIdentityRequest = { repoPath: string; scope: IdentityScope; diff --git a/src/utils/commitMessageDraft.ts b/src/utils/commitMessageDraft.ts new file mode 100644 index 0000000..9e0fac7 --- /dev/null +++ b/src/utils/commitMessageDraft.ts @@ -0,0 +1,63 @@ +export type CommitMessageDraft = { + subject: string; + body: string; + updatedAt: number; +}; + +const DRAFT_KEY_PREFIX = "gitmun.commitMessageDraft.v1:"; + +function getStorage(): Storage | null { + try { + return typeof localStorage === "undefined" ? null : localStorage; + } catch { + return null; + } +} + +function keyForRepo(repoPath: string) { + return `${DRAFT_KEY_PREFIX}${encodeURIComponent(repoPath)}`; +} + +function isDraft(value: unknown): value is CommitMessageDraft { + if (!value || typeof value !== "object") return false; + const draft = value as CommitMessageDraft; + return typeof draft.subject === "string" && typeof draft.body === "string" && typeof draft.updatedAt === "number"; +} + +export function loadCommitMessageDraft(repoPath: string): CommitMessageDraft | null { + const storage = getStorage(); + if (!storage) return null; + + try { + const parsed = JSON.parse(storage.getItem(keyForRepo(repoPath)) ?? "null"); + return isDraft(parsed) ? parsed : null; + } catch { + return null; + } +} + +export function saveCommitMessageDraft(repoPath: string, subject: string, body: string) { + const storage = getStorage(); + if (!storage) return; + + try { + if (subject.trim() === "" && body.trim() === "") { + storage.removeItem(keyForRepo(repoPath)); + return; + } + + storage.setItem(keyForRepo(repoPath), JSON.stringify({ + subject, + body, + updatedAt: Date.now(), + })); + } catch { + } +} + +export function clearCommitMessageDraft(repoPath: string) { + try { + getStorage()?.removeItem(keyForRepo(repoPath)); + } catch { + } +} diff --git a/src/utils/fileTree.test.ts b/src/utils/fileTree.test.ts index bcbb450..5618cb4 100644 --- a/src/utils/fileTree.test.ts +++ b/src/utils/fileTree.test.ts @@ -11,6 +11,16 @@ function file(path: string, additions: number | null = null, deletions: number | }; } +function directory(path: string): FileStatusItem { + return { + path, + status: "new", + additions: null, + deletions: null, + kind: "directory", + }; +} + describe("buildFileTree", () => { it("groups files by shared folders", () => { const tree = buildFileTree([ @@ -180,4 +190,29 @@ describe("buildFileTree", () => { "src/App.tsx", ]); }); + + it("creates a directory node for a directory-kind untracked entry with no children", () => { + const tree = buildFileTree([directory("drafts")]); + + expect(tree).toMatchObject([ + { + type: "directory", + name: "drafts", + path: "drafts", + selectablePath: "drafts", + status: "new", + fileCount: 1, + children: [], + }, + ]); + }); + + it("returns the directory path for selectable directory nodes", () => { + const [node] = buildFileTree([directory("drafts")]); + + expect(node.type).toBe("directory"); + if (node.type !== "directory") return; + + expect(descendantFilePaths(node)).toEqual(["drafts"]); + }); }); diff --git a/src/utils/fileTree.ts b/src/utils/fileTree.ts index 19a8e00..e97afa0 100644 --- a/src/utils/fileTree.ts +++ b/src/utils/fileTree.ts @@ -14,6 +14,8 @@ export type FileTreeDirectoryNode = { name: string; path: string; children: FileTreeNode[]; + selectablePath?: string; + status?: string; fileCount: number; additions: number; deletions: number; @@ -39,6 +41,12 @@ function createDirectory(name: string, path: string): MutableDirectoryNode { }; } +function accumulateFileStats(directory: MutableDirectoryNode, file: FileStatusItem) { + directory.fileCount += 1; + directory.additions += file.additions ?? 0; + directory.deletions += file.deletions ?? 0; +} + function sortNodes(nodes: FileTreeNode[]): FileTreeNode[] { return nodes.sort((left, right) => { if (left.type !== right.type) return left.type === "directory" ? -1 : 1; @@ -60,6 +68,8 @@ function finaliseDirectory(directory: MutableDirectoryNode): FileTreeDirectoryNo name: `${directory.name}/${child.name}`, path: child.path, children: child.children, + selectablePath: child.selectablePath, + status: child.status, fileCount: directory.fileCount, additions: directory.additions, deletions: directory.deletions, @@ -71,6 +81,8 @@ function finaliseDirectory(directory: MutableDirectoryNode): FileTreeDirectoryNo name: directory.name, path: directory.path, children, + selectablePath: directory.selectablePath, + status: directory.status, fileCount: directory.fileCount, additions: directory.additions, deletions: directory.deletions, @@ -85,9 +97,23 @@ export function buildFileTree(files: FileStatusItem[]): FileTreeNode[] { if (parts.length === 0) continue; let directory = root; - directory.fileCount += 1; - directory.additions += file.additions ?? 0; - directory.deletions += file.deletions ?? 0; + accumulateFileStats(directory, file); + + if (file.kind === "directory") { + for (let index = 0; index < parts.length; index += 1) { + const name = parts[index]; + const path = parts.slice(0, index + 1).join("/"); + const existing = directory.children.get(name); + const child = existing?.type === "directory" ? existing : createDirectory(name, path); + directory.children.set(name, child); + accumulateFileStats(child, file); + directory = child; + } + + directory.selectablePath = file.path; + directory.status = file.status; + continue; + } for (let index = 0; index < parts.length - 1; index += 1) { const name = parts[index]; @@ -95,9 +121,7 @@ export function buildFileTree(files: FileStatusItem[]): FileTreeNode[] { const existing = directory.children.get(name); const child = existing?.type === "directory" ? existing : createDirectory(name, path); directory.children.set(name, child); - child.fileCount += 1; - child.additions += file.additions ?? 0; - child.deletions += file.deletions ?? 0; + accumulateFileStats(child, file); directory = child; } @@ -116,7 +140,10 @@ export function buildFileTree(files: FileStatusItem[]): FileTreeNode[] { } export function descendantFilePaths(node: FileTreeDirectoryNode): string[] { - return node.children.flatMap((child) => - child.type === "directory" ? descendantFilePaths(child) : [child.path], - ); + return [ + ...(node.selectablePath ? [node.selectablePath] : []), + ...node.children.flatMap((child) => + child.type === "directory" ? descendantFilePaths(child) : [child.path], + ), + ]; }