diff --git a/.github/workflows/deployment-ci.yml b/.github/workflows/deployment-ci.yml index 98f9687e..105de026 100644 --- a/.github/workflows/deployment-ci.yml +++ b/.github/workflows/deployment-ci.yml @@ -10,6 +10,7 @@ on: - ".github/workflows/deploy-apt-repo.yml" - ".github/workflows/release-manual-main.yml" - "scripts/update-aur.sh" + - "scripts/cargo-flatten-dupes.sh" - "scripts/package-macos.sh" - "scripts/macos-cargo-config.sh" - "scripts/generate-homebrew-formula.sh" @@ -25,6 +26,7 @@ on: - ".github/workflows/deploy-apt-repo.yml" - ".github/workflows/release-manual-main.yml" - "scripts/update-aur.sh" + - "scripts/cargo-flatten-dupes.sh" - "scripts/package-macos.sh" - "scripts/macos-cargo-config.sh" - "scripts/generate-homebrew-formula.sh" @@ -49,6 +51,7 @@ jobs: - name: Validate shell scripts syntax run: | + bash -n scripts/cargo-flatten-dupes.sh bash -n scripts/update-aur.sh bash -n scripts/package-macos.sh bash -n scripts/notarize-macos.sh diff --git a/Cargo.lock b/Cargo.lock index ccdfb091..5d8fabea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2179,6 +2179,7 @@ dependencies = [ "gitcomet-core", "gitcomet-git", "gitcomet-git-gix", + "gitcomet-state", "gitcomet-ui", "gitcomet-ui-gpui", "mimalloc", @@ -2301,7 +2302,7 @@ dependencies = [ name = "gitcomet-win32-window-utils" version = "0.1.7" dependencies = [ - "windows 0.61.3", + "windows-sys 0.61.2", ] [[package]] @@ -3239,7 +3240,7 @@ dependencies = [ "log", "presser", "thiserror 2.0.18", - "windows 0.62.2", + "windows", ] [[package]] @@ -3356,9 +3357,9 @@ dependencies = [ "wayland-protocols-wlr", "web-time", "wgpu", - "windows 0.62.2", - "windows-core 0.62.2", - "windows-numerics 0.3.1", + "windows", + "windows-core", + "windows-numerics", "windows-registry 0.6.1", "x11-clipboard", "x11rb", @@ -3776,7 +3777,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core", ] [[package]] @@ -8075,8 +8076,8 @@ dependencies = [ "web-sys", "wgpu-naga-bridge", "wgpu-types", - "windows 0.62.2", - "windows-core 0.62.2", + "windows", + "windows-core", ] [[package]] @@ -8144,38 +8145,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections 0.2.0", - "windows-core 0.61.2", - "windows-future 0.2.1", - "windows-link 0.1.3", - "windows-numerics 0.2.0", -] - [[package]] name = "windows" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections 0.3.2", - "windows-core 0.62.2", - "windows-future 0.3.2", - "windows-numerics 0.3.1", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", ] [[package]] @@ -8184,20 +8163,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core 0.62.2", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-core", ] [[package]] @@ -8213,26 +8179,15 @@ dependencies = [ "windows-strings 0.5.1", ] -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", - "windows-threading 0.1.0", -] - [[package]] name = "windows-future" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core 0.62.2", + "windows-core", "windows-link 0.2.1", - "windows-threading 0.2.1", + "windows-threading", ] [[package]] @@ -8269,23 +8224,13 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - [[package]] name = "windows-numerics" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core 0.62.2", + "windows-core", "windows-link 0.2.1", ] @@ -8338,15 +8283,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows-strings" version = "0.5.1" @@ -8473,15 +8409,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows-threading" version = "0.2.1" diff --git a/crates/gitcomet-core/src/merge_extraction.rs b/crates/gitcomet-core/src/merge_extraction.rs index e37e99c6..9f996cad 100644 --- a/crates/gitcomet-core/src/merge_extraction.rs +++ b/crates/gitcomet-core/src/merge_extraction.rs @@ -5,13 +5,15 @@ //! into production code so it can be reused outside ad-hoc test harnesses. use crate::merge::{MergeOptions, merge_file}; -use crate::process::configure_background_command; +use crate::process::git_command; use rustc_hash::FxHashSet as HashSet; use std::collections::BTreeSet; use std::fmt; use std::io; use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; +#[cfg(test)] +use std::process::Command; +use std::process::Output; fn bytes_to_text_preserving_utf8(bytes: &[u8]) -> String { use std::fmt::Write as _; @@ -526,8 +528,7 @@ fn read_blob_bytes_optional( } fn run_git(repo: &Path, args: &[&str]) -> Result { - let mut command = Command::new("git"); - configure_background_command(&mut command); + let mut command = git_command(); command .args(args) .current_dir(repo) @@ -597,9 +598,38 @@ fn sanitize_fixture_component(path: &str) -> String { #[cfg(test)] mod tests { use super::*; + use crate::process::{ + GitExecutablePreference, current_git_executable_preference, + install_git_executable_preference, lock_git_runtime_test, + }; #[cfg(windows)] use std::sync::OnceLock; + // Keep merge-extraction tests isolated from process::tests, which mutate + // the shared git executable preference under this same mutex. + struct SystemGitRuntimeGuard { + _lock: std::sync::MutexGuard<'static, ()>, + original: GitExecutablePreference, + } + + impl SystemGitRuntimeGuard { + fn new() -> Self { + let lock = lock_git_runtime_test(); + let original = current_git_executable_preference(); + let _ = install_git_executable_preference(GitExecutablePreference::SystemPath); + Self { + _lock: lock, + original, + } + } + } + + impl Drop for SystemGitRuntimeGuard { + fn drop(&mut self) { + let _ = install_git_executable_preference(self.original.clone()); + } + } + #[cfg(windows)] fn is_git_shell_startup_failure(text: &str) -> bool { text.contains("sh.exe: *** fatal error -") @@ -760,6 +790,7 @@ mod tests { #[test] fn discovers_merge_commits_with_two_parents() { + let _git_runtime = SystemGitRuntimeGuard::new(); let repo = create_conflicting_merge_repo(); let merges = discover_merge_commits(repo.path(), 10).expect("discover merges"); assert_eq!(merges.len(), 1, "expected one merge commit"); @@ -772,6 +803,7 @@ mod tests { #[test] fn discovers_merge_commits_from_repo_subdirectory() { + let _git_runtime = SystemGitRuntimeGuard::new(); let repo = create_conflicting_merge_repo(); let subdir = repo.path().join("nested").join("dir"); std::fs::create_dir_all(&subdir).expect("create nested subdirectory"); @@ -786,6 +818,7 @@ mod tests { #[test] fn discover_merge_commits_zero_max_merges_errors() { + let _git_runtime = SystemGitRuntimeGuard::new(); let repo = create_conflicting_merge_repo(); let error = discover_merge_commits(repo.path(), 0).expect_err("expected invalid argument error"); @@ -801,6 +834,7 @@ mod tests { #[test] fn discover_merge_commits_reports_not_git_repository_stderr() { + let _git_runtime = SystemGitRuntimeGuard::new(); let tmp = tempfile::tempdir().expect("create temp dir"); let repo = tmp.path(); let error = @@ -820,6 +854,7 @@ mod tests { #[test] fn discovers_merge_commits_after_recent_octopus_merges() { + let _git_runtime = SystemGitRuntimeGuard::new(); if !require_git_shell_for_octopus_merge_tests() { return; } @@ -911,6 +946,7 @@ mod tests { #[test] fn extracts_sorted_text_cases_and_skips_binary() { + let _git_runtime = SystemGitRuntimeGuard::new(); let repo = create_conflicting_merge_repo(); let merges = discover_merge_commits(repo.path(), 10).expect("discover merges"); let cases = extract_merge_cases(repo.path(), &merges[0], 10).expect("extract cases"); @@ -933,6 +969,7 @@ mod tests { #[test] #[cfg(not(windows))] fn changed_files_handles_paths_with_newlines() { + let _git_runtime = SystemGitRuntimeGuard::new(); let tmp = tempfile::tempdir().expect("create temp dir"); let repo = tmp.path(); @@ -965,6 +1002,7 @@ mod tests { #[test] fn extracts_cases_with_missing_base_or_parent_as_empty_text() { + let _git_runtime = SystemGitRuntimeGuard::new(); let repo = create_missing_side_conflict_merge_repo(); let merges = discover_merge_commits(repo.path(), 10).expect("discover merges"); assert_eq!(merges.len(), 1, "expected one merge commit"); @@ -1023,6 +1061,7 @@ mod tests { #[test] fn git_show_missing_path_is_treated_as_absent_side() { + let _git_runtime = SystemGitRuntimeGuard::new(); let repo = create_conflicting_merge_repo(); let missing = read_blob_bytes_optional(repo.path(), "HEAD", "this-file-does-not-exist") .expect("missing path should not error"); @@ -1034,6 +1073,7 @@ mod tests { #[test] fn git_show_non_missing_errors_are_propagated() { + let _git_runtime = SystemGitRuntimeGuard::new(); let repo = create_conflicting_merge_repo(); let err = read_blob_bytes_optional(repo.path(), "definitely-not-a-ref", "a.txt") .expect_err("invalid ref should surface as git command failure"); @@ -1055,6 +1095,7 @@ mod tests { #[test] fn read_blob_bytes_optional_reads_existing_blob_content() { + let _git_runtime = SystemGitRuntimeGuard::new(); let repo = create_conflicting_merge_repo(); let content = read_blob_bytes_optional(repo.path(), "HEAD", "a.txt").expect("read existing path"); @@ -1065,6 +1106,7 @@ mod tests { #[test] fn extracts_merge_cases_from_repo_subdirectory() { + let _git_runtime = SystemGitRuntimeGuard::new(); let repo = create_conflicting_merge_repo(); let subdir = repo.path().join("nested").join("dir"); std::fs::create_dir_all(&subdir).expect("create nested subdirectory"); diff --git a/crates/gitcomet-core/src/process.rs b/crates/gitcomet-core/src/process.rs index bf4b8287..45b0849a 100644 --- a/crates/gitcomet-core/src/process.rs +++ b/crates/gitcomet-core/src/process.rs @@ -1,5 +1,105 @@ -use std::ffi::OsStr; +use crate::path_utils::canonicalize_or_original; +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; use std::process::Command; +#[cfg(test)] +use std::sync::Mutex; +use std::sync::{OnceLock, RwLock}; + +#[derive(Clone, Debug, Eq, PartialEq, Default)] +pub enum GitExecutablePreference { + #[default] + SystemPath, + Custom(PathBuf), +} + +impl GitExecutablePreference { + pub fn from_optional_path(path: Option) -> Self { + match path { + Some(path) if path.as_os_str().is_empty() => Self::Custom(PathBuf::new()), + Some(path) => Self::Custom(normalize_git_executable_path(path)), + _ => Self::SystemPath, + } + } + + pub fn custom_path(&self) -> Option<&Path> { + match self { + Self::SystemPath => None, + Self::Custom(path) => Some(path.as_path()), + } + } + + pub fn display_label(&self) -> String { + match self { + Self::SystemPath => "System PATH".to_string(), + Self::Custom(path) if path.as_os_str().is_empty() => { + "Custom executable (not selected)".to_string() + } + Self::Custom(path) => path.display().to_string(), + } + } + + fn command_program(&self) -> OsString { + match self { + Self::SystemPath => OsString::from("git"), + Self::Custom(path) => path.as_os_str().to_os_string(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum GitExecutableAvailability { + Available { version_output: String }, + Unavailable { detail: String }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GitRuntimeState { + pub preference: GitExecutablePreference, + pub availability: GitExecutableAvailability, +} + +impl Default for GitRuntimeState { + fn default() -> Self { + current_git_runtime() + } +} + +impl GitRuntimeState { + pub fn is_available(&self) -> bool { + matches!( + self.availability, + GitExecutableAvailability::Available { .. } + ) + } + + pub fn version_output(&self) -> Option<&str> { + match &self.availability { + GitExecutableAvailability::Available { version_output } => { + Some(version_output.as_str()) + } + GitExecutableAvailability::Unavailable { .. } => None, + } + } + + pub fn unavailable_detail(&self) -> Option<&str> { + match &self.availability { + GitExecutableAvailability::Available { .. } => None, + GitExecutableAvailability::Unavailable { detail } => Some(detail.as_str()), + } + } +} + +fn git_runtime_slot() -> &'static RwLock { + static SLOT: OnceLock> = OnceLock::new(); + SLOT.get_or_init(|| RwLock::new(probe_git_runtime(GitExecutablePreference::SystemPath))) +} + +#[cfg(test)] +fn git_runtime_test_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} /// Create a background subprocess command preconfigured to avoid creating a /// visible console window on Windows. @@ -9,6 +109,38 @@ pub fn background_command(program: impl AsRef) -> Command { command } +pub fn git_command() -> Command { + git_command_for_preference(¤t_git_runtime().preference) +} + +pub fn current_git_runtime() -> GitRuntimeState { + git_runtime_slot() + .read() + .unwrap_or_else(|err| err.into_inner()) + .clone() +} + +pub fn current_git_executable_preference() -> GitExecutablePreference { + current_git_runtime().preference +} + +pub fn install_git_executable_preference(preference: GitExecutablePreference) -> GitRuntimeState { + let next = probe_git_runtime(preference); + *git_runtime_slot() + .write() + .unwrap_or_else(|err| err.into_inner()) = next.clone(); + next +} + +pub fn install_git_executable_path(path: Option) -> GitRuntimeState { + install_git_executable_preference(GitExecutablePreference::from_optional_path(path)) +} + +pub fn refresh_git_runtime() -> GitRuntimeState { + let preference = current_git_executable_preference(); + install_git_executable_preference(preference) +} + /// Configure a background subprocess so it does not create a visible console /// window on Windows when GitComet is running as a GUI-subsystem app. pub fn configure_background_command(command: &mut std::process::Command) { @@ -25,3 +157,493 @@ pub fn configure_background_command(command: &mut std::process::Command) { let _ = command; } } + +pub fn normalize_git_executable_path(path: PathBuf) -> PathBuf { + if path.as_os_str().is_empty() { + return path; + } + let path = if path.is_absolute() { + path + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(path) + }; + canonicalize_or_original(path) +} + +fn git_command_for_preference(preference: &GitExecutablePreference) -> Command { + background_command(preference.command_program()) +} + +fn probe_git_runtime(preference: GitExecutablePreference) -> GitRuntimeState { + if matches!( + &preference, + GitExecutablePreference::Custom(path) if path.as_os_str().is_empty() + ) { + return GitRuntimeState { + preference, + availability: GitExecutableAvailability::Unavailable { + detail: "Custom Git executable is not configured. Choose an executable or switch back to System PATH.".to_string(), + }, + }; + } + + let executable_label = preference.display_label(); + let mut command = git_command_for_preference(&preference); + command.arg("--version"); + + let availability = match command.output() { + Ok(output) if output.status.success() => { + let version_output = if !output.stdout.is_empty() { + bytes_to_text_preserving_utf8(&output.stdout) + .trim() + .to_string() + } else { + bytes_to_text_preserving_utf8(&output.stderr) + .trim() + .to_string() + }; + if version_output.is_empty() { + GitExecutableAvailability::Unavailable { + detail: format!( + "Git executable at {executable_label} returned no version text." + ), + } + } else { + GitExecutableAvailability::Available { version_output } + } + } + Ok(output) => { + let detail = bytes_to_text_preserving_utf8(&output.stderr) + .trim() + .to_string(); + let detail = if detail.is_empty() { + format!( + "Git executable at {executable_label} exited with {status}.", + status = output.status + ) + } else { + format!("Git executable at {executable_label} failed: {detail}") + }; + GitExecutableAvailability::Unavailable { detail } + } + Err(err) => GitExecutableAvailability::Unavailable { + detail: match preference { + GitExecutablePreference::SystemPath => { + format!("Git executable was not found in System PATH: {err}") + } + GitExecutablePreference::Custom(_) => { + format!("Configured Git executable at {executable_label} is unavailable: {err}") + } + }, + }, + }; + + GitRuntimeState { + preference, + availability, + } +} + +fn bytes_to_text_preserving_utf8(bytes: &[u8]) -> String { + use std::fmt::Write as _; + + let mut out = String::with_capacity(bytes.len()); + let mut cursor = 0usize; + while cursor < bytes.len() { + match std::str::from_utf8(&bytes[cursor..]) { + Ok(valid) => { + out.push_str(valid); + break; + } + Err(err) => { + let valid_len = err.valid_up_to(); + if valid_len > 0 { + let valid = &bytes[cursor..cursor + valid_len]; + out.push_str( + std::str::from_utf8(valid) + .expect("slice identified by valid_up_to must be valid UTF-8"), + ); + cursor += valid_len; + } + + let invalid_len = err.error_len().unwrap_or(1); + let invalid_end = cursor.saturating_add(invalid_len).min(bytes.len()); + for byte in &bytes[cursor..invalid_end] { + let _ = write!(out, "\\x{byte:02x}"); + } + cursor = invalid_end; + } + } + } + + out +} + +#[cfg(test)] +pub fn lock_git_runtime_test() -> std::sync::MutexGuard<'static, ()> { + git_runtime_test_lock() + .lock() + .unwrap_or_else(|err| err.into_inner()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::Path; + + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt as _; + + struct GitRuntimePreferenceResetGuard { + original: GitExecutablePreference, + } + + impl GitRuntimePreferenceResetGuard { + fn install(preference: GitExecutablePreference) -> Self { + let original = current_git_executable_preference(); + let _ = install_git_executable_preference(preference); + Self { original } + } + } + + impl Drop for GitRuntimePreferenceResetGuard { + fn drop(&mut self) { + let _ = install_git_executable_preference(self.original.clone()); + } + } + + fn git_runtime_probe_count(probe_log: &Path) -> usize { + fs::read_to_string(probe_log) + .unwrap_or_default() + .lines() + .count() + } + + fn create_git_probe_script( + stdout: &str, + stderr: &str, + exit_code: i32, + probe_log: Option<&Path>, + ) -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().expect("create temp dir"); + #[cfg(unix)] + let script = dir.path().join("git"); + #[cfg(windows)] + let script = dir.path().join("git.cmd"); + + write_git_probe_script(&script, stdout, stderr, exit_code, probe_log); + (dir, script) + } + + #[cfg(unix)] + fn write_git_probe_script( + script_path: &Path, + stdout: &str, + stderr: &str, + exit_code: i32, + probe_log: Option<&Path>, + ) { + let mut script = String::from("#!/bin/sh\n"); + if let Some(probe_log) = probe_log { + script.push_str(&format!("printf 'probe\\n' >> '{}'\n", probe_log.display())); + } + if !stdout.is_empty() { + script.push_str(&format!("printf '%s\\n' '{stdout}'\n")); + } + if !stderr.is_empty() { + script.push_str(&format!("printf '%s\\n' '{stderr}' >&2\n")); + } + script.push_str(&format!("exit {exit_code}\n")); + + fs::write(script_path, script).expect("write git probe script"); + let mut permissions = fs::metadata(script_path) + .expect("git probe metadata") + .permissions(); + permissions.set_mode(0o700); + fs::set_permissions(script_path, permissions).expect("set git probe permissions"); + } + + #[cfg(windows)] + fn write_git_probe_script( + script_path: &Path, + stdout: &str, + stderr: &str, + exit_code: i32, + probe_log: Option<&Path>, + ) { + let mut script = String::from("@echo off\r\n"); + if let Some(probe_log) = probe_log { + script.push_str(&format!(">>\"{}\" echo probe\r\n", probe_log.display())); + } + if !stdout.is_empty() { + script.push_str(&format!("echo {stdout}\r\n")); + } + if !stderr.is_empty() { + script.push_str(&format!("1>&2 echo {stderr}\r\n")); + } + script.push_str(&format!("exit /b {exit_code}\r\n")); + fs::write(script_path, script).expect("write git probe script"); + } + + #[test] + fn normalize_git_executable_path_makes_relative_paths_absolute() { + let path = normalize_git_executable_path(PathBuf::from("test-git")); + assert!( + path.is_absolute(), + "expected absolute path, got {}", + path.display() + ); + } + + #[test] + fn git_executable_preference_from_optional_path_covers_all_variants() { + let relative = PathBuf::from("test-git"); + + assert_eq!( + GitExecutablePreference::from_optional_path(None), + GitExecutablePreference::SystemPath + ); + assert_eq!( + GitExecutablePreference::from_optional_path(Some(PathBuf::new())), + GitExecutablePreference::Custom(PathBuf::new()) + ); + assert_eq!( + GitExecutablePreference::from_optional_path(Some(relative.clone())), + GitExecutablePreference::Custom(normalize_git_executable_path(relative)) + ); + } + + #[test] + fn git_executable_preference_custom_path_and_display_label_cover_variants() { + let custom = GitExecutablePreference::Custom(PathBuf::from("/opt/git/bin/git")); + let empty_custom = GitExecutablePreference::Custom(PathBuf::new()); + + assert_eq!(GitExecutablePreference::SystemPath.custom_path(), None); + assert_eq!(custom.custom_path(), Some(Path::new("/opt/git/bin/git"))); + assert_eq!(empty_custom.custom_path(), Some(Path::new(""))); + + assert_eq!( + GitExecutablePreference::SystemPath.display_label(), + "System PATH" + ); + assert_eq!( + empty_custom.display_label(), + "Custom executable (not selected)" + ); + assert_eq!(custom.display_label(), "/opt/git/bin/git"); + } + + #[test] + fn install_git_executable_preference_reports_missing_custom_path() { + let _lock = lock_git_runtime_test(); + let missing = std::env::temp_dir().join("gitcomet-missing-git-executable"); + let _restore = GitRuntimePreferenceResetGuard::install(current_git_executable_preference()); + + let state = + install_git_executable_preference(GitExecutablePreference::Custom(missing.clone())); + + assert!(!state.is_available()); + assert_eq!( + state.preference, + GitExecutablePreference::Custom(missing.clone()) + ); + assert!( + state + .unavailable_detail() + .expect("expected unavailable detail") + .contains(&missing.display().to_string()) + ); + } + + #[test] + fn install_git_executable_preference_uses_custom_executable_stdout() { + let _lock = lock_git_runtime_test(); + let (_dir, script) = create_git_probe_script("git version 9.9.9-test", "", 0, None); + let _restore = GitRuntimePreferenceResetGuard::install(current_git_executable_preference()); + + let state = + install_git_executable_preference(GitExecutablePreference::Custom(script.clone())); + assert!(state.is_available()); + assert_eq!(state.version_output(), Some("git version 9.9.9-test")); + } + + #[test] + fn install_git_executable_preference_uses_stderr_when_stdout_is_empty() { + let _lock = lock_git_runtime_test(); + let (_dir, script) = create_git_probe_script("", "git version 8.8.8-test", 0, None); + let _restore = GitRuntimePreferenceResetGuard::install(current_git_executable_preference()); + + let state = + install_git_executable_preference(GitExecutablePreference::Custom(script.clone())); + + assert!(state.is_available()); + assert_eq!(state.version_output(), Some("git version 8.8.8-test")); + } + + #[test] + fn install_git_executable_preference_reports_empty_version_output() { + let _lock = lock_git_runtime_test(); + let (_dir, script) = create_git_probe_script("", "", 0, None); + let _restore = GitRuntimePreferenceResetGuard::install(current_git_executable_preference()); + + let state = + install_git_executable_preference(GitExecutablePreference::Custom(script.clone())); + + assert!(!state.is_available()); + assert_eq!( + state.unavailable_detail(), + Some( + &format!( + "Git executable at {} returned no version text.", + script.display() + )[..] + ) + ); + } + + #[test] + fn install_git_executable_preference_reports_process_failure_with_stderr() { + let _lock = lock_git_runtime_test(); + let (_dir, script) = create_git_probe_script("", "fatal: not a git executable", 7, None); + let _restore = GitRuntimePreferenceResetGuard::install(current_git_executable_preference()); + + let state = + install_git_executable_preference(GitExecutablePreference::Custom(script.clone())); + + assert!(!state.is_available()); + assert_eq!( + state.unavailable_detail(), + Some( + &format!( + "Git executable at {} failed: fatal: not a git executable", + script.display() + )[..] + ) + ); + } + + #[test] + fn install_git_executable_preference_reports_process_failure_without_stderr() { + let _lock = lock_git_runtime_test(); + let (_dir, script) = create_git_probe_script("", "", 7, None); + let _restore = GitRuntimePreferenceResetGuard::install(current_git_executable_preference()); + + let state = + install_git_executable_preference(GitExecutablePreference::Custom(script.clone())); + + assert!(!state.is_available()); + let detail = state + .unavailable_detail() + .expect("expected unavailable detail"); + assert!(detail.contains(&script.display().to_string())); + assert!(detail.contains("exited with")); + } + + #[test] + fn install_git_executable_preference_reports_missing_custom_selection() { + let _lock = lock_git_runtime_test(); + let _restore = GitRuntimePreferenceResetGuard::install(current_git_executable_preference()); + + let state = + install_git_executable_preference(GitExecutablePreference::Custom(PathBuf::new())); + + assert!(!state.is_available()); + assert_eq!( + state.preference, + GitExecutablePreference::Custom(PathBuf::new()) + ); + assert_eq!( + state.unavailable_detail(), + Some( + "Custom Git executable is not configured. Choose an executable or switch back to System PATH." + ) + ); + } + + #[test] + fn git_command_uses_installed_preference() { + let _lock = lock_git_runtime_test(); + let temp = tempfile::tempdir().expect("create temp dir"); + let probe_log = temp.path().join("git-probes.log"); + let (_dir, script) = + create_git_probe_script("git version 7.7.7-test", "", 0, Some(&probe_log)); + let _restore = + GitRuntimePreferenceResetGuard::install(GitExecutablePreference::Custom(script)); + + assert_eq!( + git_runtime_probe_count(&probe_log), + 1, + "installing a custom executable should probe exactly once" + ); + + let output = git_command() + .arg("--version") + .output() + .expect("run configured git command"); + assert!(output.status.success()); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "git version 7.7.7-test" + ); + assert_eq!( + git_runtime_probe_count(&probe_log), + 2, + "running the returned command should invoke the configured executable" + ); + } + + #[test] + fn refresh_git_runtime_reprobes_current_preference() { + let _lock = lock_git_runtime_test(); + let temp = tempfile::tempdir().expect("create temp dir"); + let probe_log = temp.path().join("git-probes.log"); + let (_dir, script) = + create_git_probe_script("git version 6.6.6-test", "", 0, Some(&probe_log)); + let _restore = + GitRuntimePreferenceResetGuard::install(GitExecutablePreference::Custom(script)); + + assert_eq!(git_runtime_probe_count(&probe_log), 1); + let refreshed = refresh_git_runtime(); + + assert!(refreshed.is_available()); + assert_eq!(refreshed.version_output(), Some("git version 6.6.6-test")); + assert_eq!( + git_runtime_probe_count(&probe_log), + 2, + "refresh should re-run the current runtime probe" + ); + } + + #[test] + fn bytes_to_text_preserving_utf8_preserves_valid_utf8() { + assert_eq!( + bytes_to_text_preserving_utf8("cafe 日本語".as_bytes()), + "cafe 日本語" + ); + } + + #[test] + fn bytes_to_text_preserving_utf8_escapes_invalid_bytes_without_losing_valid_segments() { + let input = b"ok\xffstill-valid\xc3\xa9\x80done"; + assert_eq!( + bytes_to_text_preserving_utf8(input), + "ok\\xffstill-validé\\x80done" + ); + } + + #[test] + fn bytes_to_text_preserving_utf8_escapes_truncated_multibyte_tail() { + assert_eq!( + bytes_to_text_preserving_utf8(b"snowman \xe2\x98"), + "snowman \\xe2\\x98" + ); + } + + #[test] + fn bytes_to_text_preserving_utf8_handles_empty_input() { + assert_eq!(bytes_to_text_preserving_utf8(b""), ""); + } +} diff --git a/crates/gitcomet-core/src/services.rs b/crates/gitcomet-core/src/services.rs index ed98abe5..dfa7793b 100644 --- a/crates/gitcomet-core/src/services.rs +++ b/crates/gitcomet-core/src/services.rs @@ -157,6 +157,12 @@ pub trait GitRepository: Send + Sync { } fn list_remotes(&self) -> Result>; fn list_remote_branches(&self) -> Result>; + fn worktree_status(&self) -> Result> { + self.status().map(|status| status.unstaged) + } + fn staged_status(&self) -> Result> { + self.status().map(|status| status.staged) + } fn status(&self) -> Result; fn upstream_divergence(&self) -> Result> { Ok(None) diff --git a/crates/gitcomet-git-gix/src/repo/mod.rs b/crates/gitcomet-git-gix/src/repo/mod.rs index b1ca2774..45378193 100644 --- a/crates/gitcomet-git-gix/src/repo/mod.rs +++ b/crates/gitcomet-git-gix/src/repo/mod.rs @@ -198,6 +198,16 @@ impl GitRepository for GixRepo { self.list_remote_branches_impl() } + fn worktree_status(&self) -> Result> { + let _scope = git_ops_trace::scope(GitOpTraceKind::Status); + self.worktree_status_impl() + } + + fn staged_status(&self) -> Result> { + let _scope = git_ops_trace::scope(GitOpTraceKind::Status); + self.staged_status_impl() + } + fn status(&self) -> Result { let _scope = git_ops_trace::scope(GitOpTraceKind::Status); self.status_impl() diff --git a/crates/gitcomet-git-gix/src/repo/status.rs b/crates/gitcomet-git-gix/src/repo/status.rs index 428e660a..b2c17365 100644 --- a/crates/gitcomet-git-gix/src/repo/status.rs +++ b/crates/gitcomet-git-gix/src/repo/status.rs @@ -152,80 +152,111 @@ impl GixRepo { } } - let mut staged = staged; - - // Some platforms may omit certain unmerged shapes (notably stage-1-only - // both-deleted conflicts) from gix status output. Supplement conflict - // entries from the index's unmerged stages only when the repository is - // in an in-progress operation or gix already surfaced conflicts. - if should_supplement_unmerged_conflicts(repo.state().is_some(), has_conflicted_unstaged) { - for (path, conflict_kind) in gix_unmerged_conflicts(&repo)? { - if let Some(entry) = unstaged.iter_mut().find(|entry| entry.path == path) { - entry.kind = FileStatusKind::Conflicted; - entry.conflict = Some(conflict_kind); - } else { - unstaged.push(FileStatus { - path, - kind: FileStatusKind::Conflicted, - conflict: Some(conflict_kind), - }); - } - } + finalize_status( + &self.spec.workdir, + &repo, + may_have_gitlinks, + staged, + unstaged, + has_conflicted_unstaged, + ) + } + + pub(super) fn worktree_status_impl(&self) -> Result> { + let repo = self._repo.to_thread_local(); + let may_have_gitlinks = self.may_have_gitlink_status_supplement(&repo); + let mut unstaged = Vec::new(); + let direct = collect_index_worktree_status_direct(&repo, &mut unstaged, may_have_gitlinks)?; + + if should_supplement_unmerged_conflicts( + repo.state().is_some(), + direct.has_conflicted_unstaged, + ) { + apply_unmerged_conflicts(&repo, &mut unstaged)?; } - // Only shell out for gitlink/submodule status when the repo is likely - // to contain submodules or gitlinks. This avoids a full `git status` - // subprocess on every refresh for the common case. if may_have_gitlinks { supplement_gitlink_status_from_porcelain( &self.spec.workdir, - &mut staged, + &mut Vec::new(), &mut unstaged, )?; } - fn kind_priority(kind: FileStatusKind) -> u8 { - match kind { - FileStatusKind::Conflicted => 5, - FileStatusKind::Renamed => 4, - FileStatusKind::Deleted => 3, - FileStatusKind::Added => 2, - FileStatusKind::Modified => 1, - FileStatusKind::Untracked => 0, - } - } + sort_and_dedup_status_entries(&mut unstaged); + Ok(unstaged) + } - fn sort_and_dedup(entries: &mut Vec) { - entries.sort_unstable_by(|a, b| { - a.path - .cmp(&b.path) - .then_with(|| kind_priority(b.kind).cmp(&kind_priority(a.kind))) - }); - entries.dedup_by(|a, b| a.path == b.path); + pub(super) fn staged_status_impl(&self) -> Result> { + let repo = self._repo.to_thread_local(); + let head_oid = super::history::gix_head_id_or_none(&repo)?; + let index_stamp = repo_file_stamp(repo.index_path().as_path()); + + if let Some(cached) = self.cached_staged_status(head_oid, &index_stamp) { + return Ok(cached); } - sort_and_dedup(&mut staged); - sort_and_dedup(&mut unstaged); + let Some(head_oid) = head_oid else { + return self.status_impl().map(|status| status.staged); + }; - // gix may report unmerged entries (conflicts) as both Index/Worktree and Tree/Index - // changes, which causes the same path to show up in both sections in the UI. Mirror - // `git status` behavior by showing conflicted paths only once. - let conflicted: HashSet = unstaged - .iter() - .filter(|e| e.kind == FileStatusKind::Conflicted) - .map(|e| e.path.clone()) - .collect(); - if !conflicted.is_empty() { - staged.retain(|e| !conflicted.contains(&e.path)); + // `tree_index_status()` diffs a tree against the index, so resolve HEAD to HEAD^{tree} + // while continuing to cache by commit id. + let head_tree_id = tree_id_for_commit(&repo, &head_oid)?; + let mut staged = collect_staged_status_from_tree_index(&repo, &head_tree_id)?; + if self.may_have_gitlink_status_supplement(&repo) { + supplement_gitlink_status_from_porcelain( + &self.spec.workdir, + &mut staged, + &mut Vec::new(), + )?; } - - Ok(RepoStatus { staged, unstaged }) + sort_and_dedup_status_entries(&mut staged); + remove_conflicted_paths_from_staged( + &mut staged, + gix_unmerged_conflicts(&repo)? + .into_iter() + .map(|(path, _)| path), + ); + self.store_staged_status_cache(Some(head_oid), index_stamp, &staged); + Ok(staged) } pub(super) fn upstream_divergence_impl(&self) -> Result> { let repo = self.reopen_repo()?; head_upstream_divergence(&repo) } + + fn cached_staged_status( + &self, + head_oid: Option, + index_stamp: &RepoFileStamp, + ) -> Option> { + let guard = self + .tree_index_cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + guard + .as_ref() + .filter(|cached| cached.head_oid == head_oid && &cached.index_stamp == index_stamp) + .map(|cached| cached.staged.clone()) + } + + fn store_staged_status_cache( + &self, + head_oid: Option, + index_stamp: RepoFileStamp, + staged: &[FileStatus], + ) { + *self + .tree_index_cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(TreeIndexCacheEntry { + head_oid, + index_stamp, + staged: staged.to_vec(), + }); + } } fn should_supplement_unmerged_conflicts( @@ -235,6 +266,118 @@ fn should_supplement_unmerged_conflicts( repo_has_in_progress_state || has_conflicted_unstaged } +fn finalize_status( + workdir: &Path, + repo: &gix::Repository, + may_have_gitlinks: bool, + mut staged: Vec, + mut unstaged: Vec, + has_conflicted_unstaged: bool, +) -> Result { + // Some platforms may omit certain unmerged shapes (notably stage-1-only both-deleted + // conflicts) from gix status output. Supplement conflict entries from the index's unmerged + // stages only when the repository is in an in-progress operation or gix already surfaced + // conflicts. + if should_supplement_unmerged_conflicts(repo.state().is_some(), has_conflicted_unstaged) { + apply_unmerged_conflicts(repo, &mut unstaged)?; + } + + // Only shell out for gitlink/submodule status when the repo is likely to contain submodules + // or gitlinks. This avoids a full `git status` subprocess on every refresh for the common + // case. + if may_have_gitlinks { + supplement_gitlink_status_from_porcelain(workdir, &mut staged, &mut unstaged)?; + } + + sort_and_dedup_status_entries(&mut staged); + sort_and_dedup_status_entries(&mut unstaged); + remove_conflicted_paths_from_staged( + &mut staged, + unstaged + .iter() + .filter(|entry| entry.kind == FileStatusKind::Conflicted) + .map(|entry| entry.path.clone()), + ); + + Ok(RepoStatus { staged, unstaged }) +} + +fn apply_unmerged_conflicts(repo: &gix::Repository, unstaged: &mut Vec) -> Result<()> { + for (path, conflict_kind) in gix_unmerged_conflicts(repo)? { + if let Some(entry) = unstaged.iter_mut().find(|entry| entry.path == path) { + entry.kind = FileStatusKind::Conflicted; + entry.conflict = Some(conflict_kind); + } else { + unstaged.push(FileStatus { + path, + kind: FileStatusKind::Conflicted, + conflict: Some(conflict_kind), + }); + } + } + Ok(()) +} + +fn tree_id_for_commit(repo: &gix::Repository, commit_id: &gix::ObjectId) -> Result { + repo.find_commit(*commit_id) + .map_err(|e| Error::new(ErrorKind::Backend(format!("gix commit lookup: {e}"))))? + .tree_id() + .map(|id| id.detach()) + .map_err(|e| Error::new(ErrorKind::Backend(format!("gix commit tree id: {e}")))) +} + +fn collect_staged_status_from_tree_index( + repo: &gix::Repository, + head_oid: &gix::ObjectId, +) -> Result> { + let index = repo + .index_or_empty() + .map_err(|e| Error::new(ErrorKind::Backend(format!("gix index: {e}"))))?; + let mut staged = Vec::new(); + repo.tree_index_status( + head_oid, + &index, + None, + gix::status::tree_index::TrackRenames::AsConfigured, + |change, _, _| { + collect_tree_index_change(change, &mut staged)?; + Ok::<_, Error>(std::ops::ControlFlow::Continue(())) + }, + ) + .map_err(|e| Error::new(ErrorKind::Backend(format!("gix tree/index status: {e}"))))?; + Ok(staged) +} + +fn kind_priority(kind: FileStatusKind) -> u8 { + match kind { + FileStatusKind::Conflicted => 5, + FileStatusKind::Renamed => 4, + FileStatusKind::Deleted => 3, + FileStatusKind::Added => 2, + FileStatusKind::Modified => 1, + FileStatusKind::Untracked => 0, + } +} + +fn sort_and_dedup_status_entries(entries: &mut Vec) { + entries.sort_unstable_by(|a, b| { + a.path + .cmp(&b.path) + .then_with(|| kind_priority(b.kind).cmp(&kind_priority(a.kind))) + }); + entries.dedup_by(|a, b| a.path == b.path); +} + +fn remove_conflicted_paths_from_staged( + staged: &mut Vec, + conflicted: impl IntoIterator, +) { + let conflicted: HashSet = conflicted.into_iter().collect(); + if !conflicted.is_empty() { + staged.retain(|entry| !conflicted.contains(&entry.path)); + } +} + fn repo_file_stamp(path: &Path) -> RepoFileStamp { match std::fs::metadata(path) { Ok(metadata) => RepoFileStamp { @@ -947,12 +1090,171 @@ fn supplement_gitlink_status_from_porcelain( mod tests { use super::{ apply_porcelain_v2_gitlink_status_record, collect_unmerged_conflicts, - conflict_kind_from_stage_mask, map_directory_entry_status, - should_supplement_unmerged_conflicts, + conflict_kind_from_stage_mask, map_directory_entry_status, map_entry_status, + map_porcelain_v2_status_char, remove_conflicted_paths_from_staged, + should_supplement_unmerged_conflicts, sort_and_dedup_status_entries, tree_id_for_commit, }; - use gitcomet_core::domain::{FileConflictKind, FileStatusKind}; + use gitcomet_core::domain::{FileConflictKind, FileStatus, FileStatusKind}; use rustc_hash::FxHashMap as HashMap; - use std::path::PathBuf; + use std::fs; + use std::path::{Path, PathBuf}; + use std::process::{Command, Output}; + use std::sync::OnceLock; + + #[cfg(unix)] + use std::{fs::Permissions, os::unix::fs::PermissionsExt as _}; + + struct TestGitEnv { + _root: tempfile::TempDir, + global_config: PathBuf, + home_dir: PathBuf, + xdg_config_home: PathBuf, + gnupg_home: PathBuf, + } + + fn ensure_isolated_git_test_env() -> &'static TestGitEnv { + static ENV: OnceLock = OnceLock::new(); + ENV.get_or_init(|| { + let root = tempfile::tempdir().expect("test git env tempdir"); + let home_dir = root.path().join("home"); + let xdg_config_home = root.path().join("xdg"); + let gnupg_home = root.path().join("gnupg"); + let global_config = root.path().join("gitconfig"); + + fs::create_dir_all(&home_dir).expect("test git home"); + fs::create_dir_all(&xdg_config_home).expect("test git xdg config home"); + fs::create_dir_all(&gnupg_home).expect("test gnupg home"); + fs::write(&global_config, "").expect("test global git config"); + + #[cfg(unix)] + fs::set_permissions(&gnupg_home, Permissions::from_mode(0o700)) + .expect("test gnupg home permissions"); + + crate::install_test_git_command_environment( + global_config.clone(), + home_dir.clone(), + xdg_config_home.clone(), + gnupg_home.clone(), + ); + + TestGitEnv { + _root: root, + global_config, + home_dir, + xdg_config_home, + gnupg_home, + } + }) + } + + fn git_command() -> Command { + let env = ensure_isolated_git_test_env(); + let mut cmd = Command::new("git"); + cmd.env("GIT_CONFIG_NOSYSTEM", "1"); + cmd.env("GIT_CONFIG_GLOBAL", &env.global_config); + cmd.env("HOME", &env.home_dir); + cmd.env("XDG_CONFIG_HOME", &env.xdg_config_home); + cmd.env("GNUPGHOME", &env.gnupg_home); + cmd.env("GIT_TERMINAL_PROMPT", "0"); + cmd.env("GCM_INTERACTIVE", "Never"); + cmd.env("GIT_ALLOW_PROTOCOL", "file"); + cmd + } + + fn git_output(workdir: &Path, args: &[&str]) -> Output { + git_command() + .arg("-C") + .arg(workdir) + .args(args) + .output() + .expect("spawn git") + } + + fn git_success(workdir: &Path, args: &[&str]) { + let output = git_output(workdir, args); + assert!( + output.status.success(), + "git {:?} failed\nstdout:\n{}\nstderr:\n{}", + args, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + fn git_expect_failure(workdir: &Path, args: &[&str]) -> Output { + let output = git_output(workdir, args); + assert!( + !output.status.success(), + "expected git {:?} to fail\nstdout:\n{}\nstderr:\n{}", + args, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + output + } + + fn init_test_repo(workdir: &Path) { + let _ = ensure_isolated_git_test_env(); + git_success(workdir, &["init"]); + for args in [ + ["config", "core.autocrlf", "false"].as_slice(), + ["config", "core.eol", "lf"].as_slice(), + ["config", "credential.helper", ""].as_slice(), + ["config", "credential.interactive", "never"].as_slice(), + ["config", "protocol.file.allow", "always"].as_slice(), + ["config", "commit.gpgsign", "false"].as_slice(), + ["config", "user.name", "Test User"].as_slice(), + ["config", "user.email", "test@example.com"].as_slice(), + ] { + git_success(workdir, args); + } + } + + fn write_file(workdir: &Path, relative: &str, contents: &str) { + let path = workdir.join(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create parent directories"); + } + fs::write(path, contents).expect("write file"); + } + + fn open_repo(workdir: &Path) -> super::super::GixRepo { + let thread_safe_repo = gix::open(workdir).expect("open repo").into_sync(); + super::super::GixRepo::new(workdir.to_path_buf(), thread_safe_repo) + } + + fn file_status(path: &str, kind: FileStatusKind) -> FileStatus { + FileStatus { + path: PathBuf::from(path), + kind, + conflict: None, + } + } + + fn conflicted_file_status(path: &str, conflict: FileConflictKind) -> FileStatus { + FileStatus { + path: PathBuf::from(path), + kind: FileStatusKind::Conflicted, + conflict: Some(conflict), + } + } + + fn setup_both_modified_text_conflict(workdir: &Path, path: &str) { + init_test_repo(workdir); + write_file(workdir, path, "base\n"); + git_success(workdir, &["add", path]); + git_success(workdir, &["commit", "-m", "base"]); + + git_success(workdir, &["checkout", "-b", "feature"]); + write_file(workdir, path, "theirs\n"); + git_success(workdir, &["commit", "-am", "theirs"]); + + git_success(workdir, &["checkout", "-"]); + write_file(workdir, path, "ours\n"); + git_success(workdir, &["commit", "-am", "ours"]); + + let _ = git_expect_failure(workdir, &["merge", "feature"]); + } #[test] fn conflict_kind_from_stage_mask_covers_all_shapes() { @@ -1055,6 +1357,63 @@ mod tests { ); } + #[test] + fn sort_and_dedup_status_entries_prefers_highest_priority_kind_per_path() { + let mut entries = vec![ + file_status("b.txt", FileStatusKind::Modified), + file_status("a.txt", FileStatusKind::Untracked), + file_status("a.txt", FileStatusKind::Deleted), + file_status("c.txt", FileStatusKind::Modified), + file_status("c.txt", FileStatusKind::Added), + file_status("d.txt", FileStatusKind::Modified), + file_status("d.txt", FileStatusKind::Renamed), + ]; + + sort_and_dedup_status_entries(&mut entries); + + assert_eq!( + entries, + vec![ + file_status("a.txt", FileStatusKind::Deleted), + file_status("b.txt", FileStatusKind::Modified), + file_status("c.txt", FileStatusKind::Added), + file_status("d.txt", FileStatusKind::Renamed), + ] + ); + } + + #[test] + fn remove_conflicted_paths_from_staged_ignores_empty_input() { + let expected = vec![file_status("a.txt", FileStatusKind::Modified)]; + let mut staged = expected.clone(); + + remove_conflicted_paths_from_staged(&mut staged, std::iter::empty::()); + + assert_eq!(staged, expected); + } + + #[test] + fn remove_conflicted_paths_from_staged_removes_only_matching_paths() { + let mut staged = vec![ + file_status("a.txt", FileStatusKind::Modified), + file_status("b.txt", FileStatusKind::Added), + file_status("c.txt", FileStatusKind::Deleted), + ]; + + remove_conflicted_paths_from_staged( + &mut staged, + [PathBuf::from("b.txt"), PathBuf::from("missing.txt")], + ); + + assert_eq!( + staged, + vec![ + file_status("a.txt", FileStatusKind::Modified), + file_status("c.txt", FileStatusKind::Deleted), + ] + ); + } + #[test] fn map_directory_entry_status_only_reports_untracked_entries() { use gix::dir::entry::Status; @@ -1075,6 +1434,83 @@ mod tests { assert_eq!(map_directory_entry_status(Status::Pruned), None); } + #[test] + fn map_entry_status_maps_all_conflict_summaries() { + use gix::status::plumbing::index_as_worktree::{Conflict, EntryStatus}; + + for (summary, expected) in [ + (Conflict::BothDeleted, FileConflictKind::BothDeleted), + (Conflict::AddedByUs, FileConflictKind::AddedByUs), + (Conflict::DeletedByThem, FileConflictKind::DeletedByThem), + (Conflict::AddedByThem, FileConflictKind::AddedByThem), + (Conflict::DeletedByUs, FileConflictKind::DeletedByUs), + (Conflict::BothAdded, FileConflictKind::BothAdded), + (Conflict::BothModified, FileConflictKind::BothModified), + ] { + assert_eq!( + map_entry_status::<(), ()>(EntryStatus::Conflict { + summary, + entries: Box::new([None, None, None]), + }), + (FileStatusKind::Conflicted, Some(expected)) + ); + } + } + + #[test] + fn map_entry_status_maps_non_conflict_variants() { + use gix::status::plumbing::index_as_worktree::{Change, EntryStatus}; + + assert_eq!( + map_entry_status::<(), ()>(EntryStatus::IntentToAdd), + (FileStatusKind::Added, None) + ); + assert_eq!( + map_entry_status::<(), ()>( + EntryStatus::NeedsUpdate(gix::index::entry::Stat::default()) + ), + (FileStatusKind::Modified, None) + ); + assert_eq!( + map_entry_status::<(), ()>(EntryStatus::Change(Change::Removed)), + (FileStatusKind::Deleted, None) + ); + assert_eq!( + map_entry_status::<(), ()>(EntryStatus::Change(Change::Type { + worktree_mode: gix::index::entry::Mode::FILE, + })), + (FileStatusKind::Modified, None) + ); + assert_eq!( + map_entry_status::<(), ()>(EntryStatus::Change(Change::Modification { + executable_bit_changed: false, + content_change: None, + set_entry_stat_size_zero: false, + })), + (FileStatusKind::Modified, None) + ); + assert_eq!( + map_entry_status::<(), ()>(EntryStatus::Change(Change::SubmoduleModification(()))), + (FileStatusKind::Modified, None) + ); + } + + #[test] + fn map_porcelain_v2_status_char_maps_supported_values() { + for (ch, expected) in [ + ('M', Some(FileStatusKind::Modified)), + ('T', Some(FileStatusKind::Modified)), + ('A', Some(FileStatusKind::Added)), + ('D', Some(FileStatusKind::Deleted)), + ('R', Some(FileStatusKind::Renamed)), + ('U', Some(FileStatusKind::Conflicted)), + ('.', None), + ('?', None), + ] { + assert_eq!(map_porcelain_v2_status_char(ch), expected); + } + } + #[test] fn supplement_unmerged_conflicts_runs_for_in_progress_repo() { assert!(should_supplement_unmerged_conflicts(true, false)); @@ -1107,6 +1543,27 @@ mod tests { assert_eq!(unstaged[0].kind, FileStatusKind::Modified); } + #[test] + fn porcelain_gitlink_record_maps_conflicted_status_chars_to_both_lanes() { + let mut staged = Vec::new(); + let mut unstaged = Vec::new(); + apply_porcelain_v2_gitlink_status_record( + b"1 UU SC.. 160000 160000 160000 1111111111111111111111111111111111111111 2222222222222222222222222222222222222222 chess3", + &mut staged, + &mut unstaged, + ) + .unwrap(); + + assert_eq!( + staged, + vec![file_status("chess3", FileStatusKind::Conflicted)] + ); + assert_eq!( + unstaged, + vec![file_status("chess3", FileStatusKind::Conflicted)] + ); + } + #[test] fn porcelain_gitlink_record_maps_added_and_unstaged_modified() { let mut staged = Vec::new(); @@ -1127,6 +1584,51 @@ mod tests { assert_eq!(unstaged[0].kind, FileStatusKind::Modified); } + #[test] + fn porcelain_gitlink_record_ignores_non_type_one_records() { + let mut staged = Vec::new(); + let mut unstaged = Vec::new(); + apply_porcelain_v2_gitlink_status_record( + b"2 R. N... 160000 160000 160000 111 222 chess3", + &mut staged, + &mut unstaged, + ) + .unwrap(); + + assert!(staged.is_empty()); + assert!(unstaged.is_empty()); + } + + #[test] + fn porcelain_gitlink_record_ignores_non_gitlink_modes() { + let mut staged = Vec::new(); + let mut unstaged = Vec::new(); + apply_porcelain_v2_gitlink_status_record( + b"1 M. N... 100644 100644 100644 111 222 chess3", + &mut staged, + &mut unstaged, + ) + .unwrap(); + + assert!(staged.is_empty()); + assert!(unstaged.is_empty()); + } + + #[test] + fn porcelain_gitlink_record_ignores_missing_path() { + let mut staged = Vec::new(); + let mut unstaged = Vec::new(); + apply_porcelain_v2_gitlink_status_record( + b"1 M. SC.. 160000 160000 160000 111 222 ", + &mut staged, + &mut unstaged, + ) + .unwrap(); + + assert!(staged.is_empty()); + assert!(unstaged.is_empty()); + } + #[test] fn porcelain_gitlink_record_preserves_spaces_in_path() { let mut staged = Vec::new(); @@ -1160,4 +1662,115 @@ mod tests { assert_eq!(unstaged[0].path.as_os_str().as_bytes(), b"submodule-\xff"); assert_eq!(unstaged[0].kind, FileStatusKind::Modified); } + + #[test] + fn status_impl_matches_lane_specific_statuses() { + let tmp = tempfile::tempdir().expect("tempdir"); + let workdir = tmp.path(); + init_test_repo(workdir); + + write_file(workdir, "staged.txt", "base\n"); + write_file(workdir, "unstaged.txt", "base\n"); + git_success(workdir, &["add", "staged.txt", "unstaged.txt"]); + git_success(workdir, &["commit", "-m", "initial"]); + + write_file(workdir, "staged.txt", "staged change\n"); + git_success(workdir, &["add", "staged.txt"]); + write_file(workdir, "unstaged.txt", "unstaged change\n"); + write_file(workdir, "untracked.txt", "untracked\n"); + + let gix_repo = open_repo(workdir); + let combined = gix_repo.status_impl().expect("combined status"); + let staged = gix_repo.staged_status_impl().expect("staged status"); + let unstaged = gix_repo.worktree_status_impl().expect("worktree status"); + + assert_eq!(combined.staged, staged); + assert_eq!(combined.unstaged, unstaged); + assert_eq!( + staged, + vec![file_status("staged.txt", FileStatusKind::Modified)] + ); + assert_eq!( + unstaged, + vec![ + file_status("unstaged.txt", FileStatusKind::Modified), + file_status("untracked.txt", FileStatusKind::Untracked), + ] + ); + } + + #[test] + fn staged_status_impl_on_unborn_head_uses_combined_status() { + let tmp = tempfile::tempdir().expect("tempdir"); + let workdir = tmp.path(); + init_test_repo(workdir); + + write_file(workdir, "new.txt", "new\n"); + git_success(workdir, &["add", "new.txt"]); + + let gix_repo = open_repo(workdir); + let combined = gix_repo.status_impl().expect("combined status"); + let staged = gix_repo.staged_status_impl().expect("staged status"); + + assert_eq!(combined.staged, staged); + assert_eq!(staged, vec![file_status("new.txt", FileStatusKind::Added)]); + assert!(combined.unstaged.is_empty()); + } + + #[test] + fn status_impl_removes_conflicted_paths_from_staged_lane() { + let tmp = tempfile::tempdir().expect("tempdir"); + let workdir = tmp.path(); + setup_both_modified_text_conflict(workdir, "tracked.txt"); + + let gix_repo = open_repo(workdir); + let combined = gix_repo.status_impl().expect("combined status"); + let staged = gix_repo.staged_status_impl().expect("staged status"); + let worktree = gix_repo.worktree_status_impl().expect("worktree status"); + + assert!(combined.staged.is_empty()); + assert!(staged.is_empty()); + assert_eq!(combined.unstaged, worktree); + assert_eq!( + worktree, + vec![conflicted_file_status( + "tracked.txt", + FileConflictKind::BothModified, + )] + ); + } + + #[test] + fn staged_status_impl_resolves_head_to_tree_before_tree_index_diff() { + let tmp = tempfile::tempdir().expect("tempdir"); + let workdir = tmp.path(); + init_test_repo(workdir); + + fs::write(workdir.join("tracked.txt"), "base\n").expect("write tracked file"); + git_success(workdir, &["add", "tracked.txt"]); + git_success(workdir, &["commit", "-m", "initial"]); + + fs::write(workdir.join("tracked.txt"), "base\nchanged\n").expect("rewrite tracked file"); + git_success(workdir, &["add", "tracked.txt"]); + + let thread_safe_repo = gix::open(workdir).expect("open repo").into_sync(); + let gix_repo = super::super::GixRepo::new(workdir.to_path_buf(), thread_safe_repo); + + let head_commit_id = super::super::history::gix_head_id_or_none( + &gix_repo.reopen_repo().expect("reopen repo"), + ) + .expect("head lookup") + .expect("head commit"); + let head_tree_id = tree_id_for_commit( + &gix_repo.reopen_repo().expect("reopen repo"), + &head_commit_id, + ) + .expect("head tree"); + assert_ne!(head_commit_id, head_tree_id); + + let staged = gix_repo.staged_status_impl().expect("staged status"); + assert_eq!(staged.len(), 1); + assert_eq!(staged[0].path, PathBuf::from("tracked.txt")); + assert_eq!(staged[0].kind, FileStatusKind::Modified); + } } diff --git a/crates/gitcomet-git-gix/src/util.rs b/crates/gitcomet-git-gix/src/util.rs index c8c4acbb..104b7372 100644 --- a/crates/gitcomet-git-gix/src/util.rs +++ b/crates/gitcomet-git-gix/src/util.rs @@ -9,7 +9,7 @@ use gitcomet_core::auth::{ }; use gitcomet_core::domain::{Commit, CommitId, CommitParentIds, LogPage}; use gitcomet_core::error::{Error, ErrorKind, GitFailure, GitFailureId}; -use gitcomet_core::process::configure_background_command; +use gitcomet_core::process::{configure_background_command, git_command}; use gitcomet_core::services::{CommandOutput, Result}; use std::fs; use std::io; @@ -173,8 +173,7 @@ fn apply_test_git_command_environment(cmd: &mut Command) { } pub(crate) fn git_workdir_cmd_for(workdir: &Path) -> Command { - let mut cmd = Command::new("git"); - configure_background_command(&mut cmd); + let mut cmd = git_command(); apply_test_git_command_environment(&mut cmd); cmd.arg("-C").arg(workdir); cmd diff --git a/crates/gitcomet-state/src/model.rs b/crates/gitcomet-state/src/model.rs index fd6ef36e..2060da15 100644 --- a/crates/gitcomet-state/src/model.rs +++ b/crates/gitcomet-state/src/model.rs @@ -5,7 +5,9 @@ use gitcomet_core::conflict_session::{ ConflictPayload, ConflictSession, ConflictStageParts, canonicalize_stage_parts, }; use gitcomet_core::domain::*; +use gitcomet_core::process::GitRuntimeState; use gitcomet_core::services::BlameLine; +use serde::{Deserialize, Serialize}; use std::collections::VecDeque; use std::path::PathBuf; use std::sync::Arc; @@ -20,6 +22,39 @@ pub struct SidebarDataRequest { pub stashes: bool, } +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +#[derive(Default)] +pub enum GitLogTagFetchMode { + #[default] + OnRepositoryActivation, + Disabled, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GitLogSettings { + pub show_history_tags: bool, + pub tag_fetch_mode: GitLogTagFetchMode, +} + +impl Default for GitLogSettings { + fn default() -> Self { + Self { + show_history_tags: true, + tag_fetch_mode: GitLogTagFetchMode::OnRepositoryActivation, + } + } +} + +impl GitLogSettings { + pub fn auto_fetch_tags_on_repo_activation(self) -> bool { + matches!( + self.tag_fetch_mode, + GitLogTagFetchMode::OnRepositoryActivation + ) + } +} + #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct RepoLoadsInFlight { in_flight: u32, @@ -41,19 +76,21 @@ impl RepoLoadsInFlight { pub const TAGS: u32 = 1 << 3; pub const REMOTES: u32 = 1 << 4; pub const REMOTE_BRANCHES: u32 = 1 << 5; - pub const STATUS: u32 = 1 << 6; - pub const STASHES: u32 = 1 << 7; - pub const REFLOG: u32 = 1 << 8; - pub const REBASE_STATE: u32 = 1 << 9; - pub const LOG: u32 = 1 << 10; - pub const MERGE_COMMIT_MESSAGE: u32 = 1 << 11; - pub const REMOTE_TAGS: u32 = 1 << 12; - pub const WORKTREES: u32 = 1 << 13; + pub const WORKTREE_STATUS: u32 = 1 << 6; + pub const STAGED_STATUS: u32 = 1 << 7; + pub const STASHES: u32 = 1 << 8; + pub const REFLOG: u32 = 1 << 9; + pub const REBASE_STATE: u32 = 1 << 10; + pub const LOG: u32 = 1 << 11; + pub const MERGE_COMMIT_MESSAGE: u32 = 1 << 12; + pub const REMOTE_TAGS: u32 = 1 << 13; + pub const WORKTREES: u32 = 1 << 14; const PRIMARY_REFRESH_FLAGS: u32 = Self::HEAD_BRANCH | Self::UPSTREAM_DIVERGENCE | Self::REBASE_STATE | Self::MERGE_COMMIT_MESSAGE - | Self::STATUS + | Self::WORKTREE_STATUS + | Self::STAGED_STATUS | Self::LOG; pub fn is_in_flight(&self, flag: u32) -> bool { @@ -238,6 +275,8 @@ pub struct AppState { pub notifications: Vec, pub banner_error: Option, pub auth_prompt: Option, + pub git_runtime: GitRuntimeState, + pub git_log_settings: GitLogSettings, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -446,6 +485,7 @@ impl Default for ConflictState { } const BRANCH_SIDEBAR_REV_MIX: u64 = 0x9e37_79b9_7f4a_7c15; +const STATUS_CACHE_REV_MIX: u64 = 0x517c_c1b7_2722_0a95; #[inline] fn mix_branch_sidebar_revs(values: [u64; 7]) -> u64 { @@ -457,6 +497,16 @@ fn mix_branch_sidebar_revs(values: [u64; 7]) -> u64 { acc } +#[inline] +fn mix_status_cache_revs(values: [u64; 2]) -> u64 { + let mut acc = STATUS_CACHE_REV_MIX; + for value in values { + acc ^= value.wrapping_mul(STATUS_CACHE_REV_MIX); + acc = acc.rotate_left(9).wrapping_add(STATUS_CACHE_REV_MIX); + } + acc +} + #[derive(Clone, Debug)] pub struct RepoState { pub id: RepoId, @@ -487,10 +537,15 @@ pub struct RepoState { pub remotes_rev: u64, pub remote_branches: Loadable>>, pub remote_branches_rev: u64, + pub worktree_status: Loadable>>, + pub worktree_status_rev: u64, + pub staged_status: Loadable>>, + pub staged_status_rev: u64, pub status: Loadable>, pub status_rev: u64, - /// Cached flag: true when `status` is `Ready` and at least one unstaged - /// entry has `FileStatusKind::Conflicted`. Recomputed in `set_status`. + /// Cached flag: true when the current unstaged/worktree lane contains at + /// least one `FileStatusKind::Conflicted` entry. Recomputed in + /// `set_worktree_status` and `set_status`. pub has_unstaged_conflicts: bool, pub log: Loadable>, pub log_loading_more: bool, @@ -555,6 +610,10 @@ impl RepoState { remotes_rev: 0, remote_branches: Loadable::NotLoaded, remote_branches_rev: 0, + worktree_status: Loadable::NotLoaded, + worktree_status_rev: 0, + staged_status: Loadable::NotLoaded, + staged_status_rev: 0, status: Loadable::NotLoaded, status_rev: 0, has_unstaged_conflicts: false, @@ -716,18 +775,136 @@ impl RepoState { self.sidebar_data_request = request; } - pub(crate) fn set_status(&mut self, status: Loadable>) { - if self.status == status { + pub(crate) fn set_worktree_status(&mut self, status: Loadable>) { + let status = loadable_into_arc(status); + if self.worktree_status == status { return; } + self.has_unstaged_conflicts = matches!( + &status, + Loadable::Ready(entries) + if entries.iter().any(|entry| entry.kind == FileStatusKind::Conflicted) + ); + self.worktree_status = status; + self.worktree_status_rev = self.worktree_status_rev.wrapping_add(1); + } + + pub(crate) fn set_staged_status(&mut self, status: Loadable>) { + let status = loadable_into_arc(status); + if self.staged_status == status { + return; + } + self.staged_status = status; + self.staged_status_rev = self.staged_status_rev.wrapping_add(1); + } + + pub(crate) fn set_status(&mut self, status: Loadable>) { + let next_worktree = match &status { + Loadable::NotLoaded => Loadable::NotLoaded, + Loadable::Loading => Loadable::Loading, + Loadable::Error(err) => Loadable::Error(err.clone()), + Loadable::Ready(status) => Loadable::Ready(Arc::new(status.unstaged.clone())), + }; + let next_staged = match &status { + Loadable::NotLoaded => Loadable::NotLoaded, + Loadable::Loading => Loadable::Loading, + Loadable::Error(err) => Loadable::Error(err.clone()), + Loadable::Ready(status) => Loadable::Ready(Arc::new(status.staged.clone())), + }; + if self.worktree_status != next_worktree { + self.worktree_status = next_worktree; + self.worktree_status_rev = self.worktree_status_rev.wrapping_add(1); + } self.has_unstaged_conflicts = matches!( &status, Loadable::Ready(s) if s.unstaged.iter().any(|e| e.kind == FileStatusKind::Conflicted) ); + if self.staged_status != next_staged { + self.staged_status = next_staged; + self.staged_status_rev = self.staged_status_rev.wrapping_add(1); + } + if self.status == status { + return; + } self.status = status; self.status_rev = self.status_rev.wrapping_add(1); } + pub fn worktree_status_entries(&self) -> Option<&[FileStatus]> { + match &self.worktree_status { + Loadable::Ready(entries) => Some(entries.as_slice()), + _ => match &self.status { + Loadable::Ready(status) => Some(status.unstaged.as_slice()), + _ => None, + }, + } + } + + pub fn staged_status_entries(&self) -> Option<&[FileStatus]> { + match &self.staged_status { + Loadable::Ready(entries) => Some(entries.as_slice()), + _ => match &self.status { + Loadable::Ready(status) => Some(status.staged.as_slice()), + _ => None, + }, + } + } + + pub fn status_entries_for_area(&self, area: DiffArea) -> Option<&[FileStatus]> { + match area { + DiffArea::Unstaged => self.worktree_status_entries(), + DiffArea::Staged => self.staged_status_entries(), + } + } + + pub fn status_entry_for_path( + &self, + area: DiffArea, + path: &std::path::Path, + ) -> Option<&FileStatus> { + self.status_entries_for_area(area)? + .iter() + .find(|entry| entry.path == path) + } + + pub fn worktree_status_cache_rev(&self) -> u64 { + if self.worktree_status_rev != 0 || !matches!(self.worktree_status, Loadable::NotLoaded) { + self.worktree_status_rev + } else { + self.status_rev + } + } + + pub fn staged_status_cache_rev(&self) -> u64 { + if self.staged_status_rev != 0 || !matches!(self.staged_status, Loadable::NotLoaded) { + self.staged_status_rev + } else { + self.status_rev + } + } + + pub fn status_cache_rev(&self) -> u64 { + let worktree = self.worktree_status_cache_rev(); + let staged = self.staged_status_cache_rev(); + if worktree == 0 && staged == 0 { + 0 + } else { + mix_status_cache_revs([worktree, staged]) + } + } + + pub fn worktree_status_is_loading(&self) -> bool { + matches!(self.worktree_status, Loadable::Loading) + || (matches!(self.worktree_status, Loadable::NotLoaded) + && matches!(self.status, Loadable::Loading)) + } + + pub fn staged_status_is_loading(&self) -> bool { + matches!(self.staged_status, Loadable::Loading) + || (matches!(self.staged_status, Loadable::NotLoaded) + && matches!(self.status, Loadable::Loading)) + } + pub(crate) fn set_log(&mut self, log: Loadable>) { if self.history_state.log == log && self.log == log { return; @@ -977,6 +1154,14 @@ mod tests { ) } + fn file_status(path: &str, kind: FileStatusKind) -> FileStatus { + FileStatus { + path: PathBuf::from(path), + kind, + conflict: None, + } + } + #[test] fn request_primary_refresh_batch_marks_all_primary_loads_when_idle() { let mut loads = RepoLoadsInFlight::default(); @@ -986,18 +1171,19 @@ mod tests { assert!(loads.is_in_flight(RepoLoadsInFlight::UPSTREAM_DIVERGENCE)); assert!(loads.is_in_flight(RepoLoadsInFlight::REBASE_STATE)); assert!(loads.is_in_flight(RepoLoadsInFlight::MERGE_COMMIT_MESSAGE)); - assert!(loads.is_in_flight(RepoLoadsInFlight::STATUS)); + assert!(loads.is_in_flight(RepoLoadsInFlight::WORKTREE_STATUS)); + assert!(loads.is_in_flight(RepoLoadsInFlight::STAGED_STATUS)); assert!(loads.is_in_flight(RepoLoadsInFlight::LOG)); } #[test] fn request_primary_refresh_batch_skips_when_any_load_is_already_in_flight() { let mut loads = RepoLoadsInFlight::default(); - assert!(loads.request(RepoLoadsInFlight::STATUS)); + assert!(loads.request(RepoLoadsInFlight::WORKTREE_STATUS)); assert!(!loads.request_primary_refresh_batch()); assert!(!loads.is_in_flight(RepoLoadsInFlight::HEAD_BRANCH)); - assert!(loads.is_in_flight(RepoLoadsInFlight::STATUS)); + assert!(loads.is_in_flight(RepoLoadsInFlight::WORKTREE_STATUS)); assert!(!loads.is_in_flight(RepoLoadsInFlight::LOG)); } @@ -1031,6 +1217,60 @@ mod tests { assert_eq!(repo.status_rev, before + 2); } + #[test] + fn split_status_setters_do_not_bump_legacy_status_rev() { + let mut repo = new_repo(); + repo.set_status(Loadable::Loading); + let status_rev = repo.status_rev; + + repo.set_worktree_status(Loadable::Ready(vec![file_status( + "src/lib.rs", + FileStatusKind::Modified, + )])); + assert_eq!(repo.status_rev, status_rev); + + repo.set_staged_status(Loadable::Ready(vec![file_status( + "src/lib.rs", + FileStatusKind::Added, + )])); + assert_eq!(repo.status_rev, status_rev); + } + + #[test] + fn status_entry_for_path_prefers_split_lane_entries() { + let mut repo = new_repo(); + repo.status = Loadable::Ready(Arc::new(RepoStatus { + unstaged: vec![file_status("legacy.rs", FileStatusKind::Modified)], + staged: vec![file_status("legacy-stage.rs", FileStatusKind::Added)], + })); + repo.status_rev = 1; + repo.set_worktree_status(Loadable::Ready(vec![file_status( + "split.rs", + FileStatusKind::Deleted, + )])); + + let entry = repo + .status_entry_for_path(DiffArea::Unstaged, std::path::Path::new("split.rs")) + .expect("split lane entry"); + assert_eq!(entry.kind, FileStatusKind::Deleted); + assert!( + repo.status_entry_for_path(DiffArea::Unstaged, std::path::Path::new("legacy.rs")) + .is_none() + ); + } + + #[test] + fn status_cache_rev_changes_with_split_lane_revisions() { + let mut repo = new_repo(); + let initial = repo.status_cache_rev(); + repo.set_worktree_status(Loadable::Loading); + let after_worktree = repo.status_cache_rev(); + assert_ne!(after_worktree, initial); + + repo.set_staged_status(Loadable::Loading); + assert_ne!(repo.status_cache_rev(), after_worktree); + } + #[test] fn set_log_bumps_log_rev() { let mut repo = new_repo(); diff --git a/crates/gitcomet-state/src/msg/effect.rs b/crates/gitcomet-state/src/msg/effect.rs index 904856b8..89984257 100644 --- a/crates/gitcomet-state/src/msg/effect.rs +++ b/crates/gitcomet-state/src/msg/effect.rs @@ -25,6 +25,12 @@ pub enum Effect { LoadRemoteBranches { repo_id: RepoId, }, + LoadWorktreeStatus { + repo_id: RepoId, + }, + LoadStagedStatus { + repo_id: RepoId, + }, LoadStatus { repo_id: RepoId, }, diff --git a/crates/gitcomet-state/src/msg/message.rs b/crates/gitcomet-state/src/msg/message.rs index 65d4f7cd..3b131a6e 100644 --- a/crates/gitcomet-state/src/msg/message.rs +++ b/crates/gitcomet-state/src/msg/message.rs @@ -1,7 +1,9 @@ +use crate::model::GitLogTagFetchMode; use crate::model::{ConflictFileLoadMode, RepoId, SidebarDataRequest}; use gitcomet_core::conflict_session::ConflictSession; use gitcomet_core::domain::*; use gitcomet_core::error::Error; +use gitcomet_core::process::GitRuntimeState; use gitcomet_core::services::GitRepository; use gitcomet_core::services::{CommandOutput, ConflictSide, PullMode, RemoteUrlKind, ResetMode}; use std::path::PathBuf; @@ -88,6 +90,11 @@ pub enum Msg { secret: String, }, CancelAuthPrompt, + SetGitRuntimeState(GitRuntimeState), + SetGitLogSettings { + show_history_tags: bool, + tag_fetch_mode: GitLogTagFetchMode, + }, SetActiveRepo { repo_id: RepoId, }, @@ -162,6 +169,12 @@ pub enum Msg { LoadSubmodules { repo_id: RepoId, }, + LoadTags { + repo_id: RepoId, + }, + LoadRemoteTags { + repo_id: RepoId, + }, RefreshBranches { repo_id: RepoId, }, @@ -515,6 +528,14 @@ pub enum InternalMsg { repo_id: RepoId, result: Result, Error>, }, + WorktreeStatusLoaded { + repo_id: RepoId, + result: Result, Error>, + }, + StagedStatusLoaded { + repo_id: RepoId, + result: Result, Error>, + }, StatusLoaded { repo_id: RepoId, result: Result, diff --git a/crates/gitcomet-state/src/msg/message_debug.rs b/crates/gitcomet-state/src/msg/message_debug.rs index f1e01f78..fbb9f0b4 100644 --- a/crates/gitcomet-state/src/msg/message_debug.rs +++ b/crates/gitcomet-state/src/msg/message_debug.rs @@ -55,6 +55,16 @@ impl std::fmt::Debug for InternalMsg { .field("repo_id", repo_id) .field("result", result) .finish(), + InternalMsg::WorktreeStatusLoaded { repo_id, result } => f + .debug_struct("WorktreeStatusLoaded") + .field("repo_id", repo_id) + .field("result", result) + .finish(), + InternalMsg::StagedStatusLoaded { repo_id, result } => f + .debug_struct("StagedStatusLoaded") + .field("repo_id", repo_id) + .field("result", result) + .finish(), InternalMsg::StatusLoaded { repo_id, result } => f .debug_struct("StatusLoaded") .field("repo_id", repo_id) diff --git a/crates/gitcomet-state/src/msg/repo_external_change.rs b/crates/gitcomet-state/src/msg/repo_external_change.rs index 13fe4b1d..47e80cc2 100644 --- a/crates/gitcomet-state/src/msg/repo_external_change.rs +++ b/crates/gitcomet-state/src/msg/repo_external_change.rs @@ -1,6 +1,53 @@ -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum RepoExternalChange { - Worktree, - GitState, - Both, +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct RepoExternalChange { + pub worktree: bool, + pub index: bool, + pub git_state: bool, +} + +impl RepoExternalChange { + #[allow(non_upper_case_globals)] + pub const Worktree: Self = Self::worktree(); + #[allow(non_upper_case_globals)] + pub const Index: Self = Self::index(); + #[allow(non_upper_case_globals)] + pub const GitState: Self = Self::git_state(); + #[allow(non_upper_case_globals)] + pub const Both: Self = Self::all(); + + pub const fn worktree() -> Self { + Self { + worktree: true, + index: false, + git_state: false, + } + } + + pub const fn index() -> Self { + Self { + worktree: false, + index: true, + git_state: false, + } + } + + pub const fn git_state() -> Self { + Self { + worktree: false, + index: false, + git_state: true, + } + } + + pub const fn all() -> Self { + Self { + worktree: true, + index: true, + git_state: true, + } + } + + pub const fn is_empty(self) -> bool { + !self.worktree && !self.index && !self.git_state + } } diff --git a/crates/gitcomet-state/src/session.rs b/crates/gitcomet-state/src/session.rs index 81ee6015..91c1f0b4 100644 --- a/crates/gitcomet-state/src/session.rs +++ b/crates/gitcomet-state/src/session.rs @@ -1,4 +1,4 @@ -use crate::model::{AppState, RepoId}; +use crate::model::{AppState, GitLogTagFetchMode, RepoId}; use gitcomet_core::domain::LogScope; use rustc_hash::FxHashSet; use serde::{Deserialize, Serialize}; @@ -32,9 +32,13 @@ pub struct UiSession { pub diff_scroll_sync: Option, pub change_tracking_height: Option, pub untracked_height: Option, + pub history_show_graph: Option, pub history_show_author: Option, pub history_show_date: Option, pub history_show_sha: Option, + pub history_show_tags: Option, + pub history_tag_fetch_mode: Option, + pub git_executable_path: Option, } #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -91,9 +95,13 @@ struct UiSessionFileV2 { diff_scroll_sync: Option, change_tracking_height: Option, untracked_height: Option, + history_show_graph: Option, history_show_author: Option, history_show_date: Option, history_show_sha: Option, + history_show_tags: Option, + history_tag_fetch_mode: Option, + git_executable_path: Option, repo_history_scopes: Option>, repo_fetch_prune_deleted_remote_tracking_branches: Option>, } @@ -147,9 +155,16 @@ pub fn load_from_path(path: &Path) -> UiSession { diff_scroll_sync: file.diff_scroll_sync, change_tracking_height: file.change_tracking_height, untracked_height: file.untracked_height, + history_show_graph: file.history_show_graph, history_show_author: file.history_show_author, history_show_date: file.history_show_date, history_show_sha: file.history_show_sha, + history_show_tags: file.history_show_tags, + history_tag_fetch_mode: file.history_tag_fetch_mode, + git_executable_path: file + .git_executable_path + .as_deref() + .map(path_from_storage_key), } } @@ -350,9 +365,13 @@ pub struct UiSettings { pub diff_scroll_sync: Option, pub change_tracking_height: Option, pub untracked_height: Option, + pub history_show_graph: Option, pub history_show_author: Option, pub history_show_date: Option, pub history_show_sha: Option, + pub history_show_tags: Option, + pub history_tag_fetch_mode: Option, + pub git_executable_path: Option>, } pub fn persist_ui_settings(settings: UiSettings) -> io::Result<()> { @@ -412,6 +431,9 @@ pub fn persist_ui_settings_to_path(settings: UiSettings, path: &Path) -> io::Res if let Some(value) = settings.untracked_height { file.untracked_height = Some(value); } + if let Some(value) = settings.history_show_graph { + file.history_show_graph = Some(value); + } if let Some(value) = settings.history_show_author { file.history_show_author = Some(value); } @@ -421,6 +443,15 @@ pub fn persist_ui_settings_to_path(settings: UiSettings, path: &Path) -> io::Res if let Some(value) = settings.history_show_sha { file.history_show_sha = Some(value); } + if let Some(value) = settings.history_show_tags { + file.history_show_tags = Some(value); + } + if let Some(value) = settings.history_tag_fetch_mode { + file.history_tag_fetch_mode = Some(value); + } + if let Some(path) = settings.git_executable_path { + file.git_executable_path = path.map(|path| path_storage_key(&path)); + } persist_to_path(path, &file) } @@ -1545,6 +1576,8 @@ mod tests { history_show_author: None, history_show_date: None, history_show_sha: None, + git_executable_path: None, + ..UiSettings::default() }, &path, ) @@ -1602,6 +1635,8 @@ mod tests { history_show_author: None, history_show_date: None, history_show_sha: None, + git_executable_path: None, + ..UiSettings::default() }, &path, ) @@ -1656,6 +1691,8 @@ mod tests { history_show_author: None, history_show_date: None, history_show_sha: None, + git_executable_path: None, + ..UiSettings::default() }, &path, ) @@ -1710,6 +1747,8 @@ mod tests { history_show_author: None, history_show_date: None, history_show_sha: None, + git_executable_path: None, + ..UiSettings::default() }, &path, ) @@ -1764,6 +1803,8 @@ mod tests { history_show_author: None, history_show_date: None, history_show_sha: None, + git_executable_path: None, + ..UiSettings::default() }, &path, ) @@ -1821,6 +1862,8 @@ mod tests { history_show_author: None, history_show_date: None, history_show_sha: None, + git_executable_path: None, + ..UiSettings::default() }, &path, ) @@ -1875,6 +1918,8 @@ mod tests { history_show_author: None, history_show_date: None, history_show_sha: None, + git_executable_path: None, + ..UiSettings::default() }, &path, ) @@ -1930,6 +1975,8 @@ mod tests { history_show_author: None, history_show_date: None, history_show_sha: None, + git_executable_path: None, + ..UiSettings::default() }, &path, ) @@ -1938,6 +1985,63 @@ mod tests { let loaded = load_from_path(&path); assert_eq!(loaded.theme_mode.as_deref(), Some("dark")); } + + #[test] + fn persist_ui_settings_round_trips_empty_custom_git_executable_path() { + let dir = env::temp_dir().join(format!( + "gitcomet-ui-settings-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + let _ = fs::create_dir_all(&dir); + let path = dir.join("session.json"); + + persist_to_path( + &path, + &UiSessionFileV2 { + version: CURRENT_SESSION_FILE_VERSION, + open_repos: Vec::new(), + active_repo: None, + ..UiSessionFileV2::default() + }, + ) + .expect("seed session file"); + + persist_ui_settings_to_path( + UiSettings { + window_width: None, + window_height: None, + sidebar_width: None, + details_width: None, + repo_sidebar_collapsed_items: None, + theme_mode: None, + ui_font_family: None, + editor_font_family: None, + use_font_ligatures: None, + date_time_format: None, + timezone: None, + show_timezone: None, + change_tracking_view: None, + diff_scroll_sync: None, + change_tracking_height: None, + untracked_height: None, + history_show_author: None, + history_show_date: None, + history_show_sha: None, + git_executable_path: Some(Some(PathBuf::new())), + ..UiSettings::default() + }, + &path, + ) + .expect("persist ui settings"); + + let loaded = load_from_path(&path); + assert_eq!(loaded.git_executable_path, Some(PathBuf::new())); + } + #[test] fn persist_repo_history_scope_round_trips() { let dir = env::temp_dir().join(format!( diff --git a/crates/gitcomet-state/src/store/effects.rs b/crates/gitcomet-state/src/store/effects.rs index 6a8426d0..3662be02 100644 --- a/crates/gitcomet-state/src/store/effects.rs +++ b/crates/gitcomet-state/src/store/effects.rs @@ -6,9 +6,11 @@ mod repo_load; mod util; use crate::model::AppState; -use crate::msg::{Effect, Msg}; +use crate::msg::{Effect, Msg, RepoCommandKind}; use crate::session; use gitcomet_core::domain::DiffTarget; +use gitcomet_core::error::{Error, ErrorKind}; +use gitcomet_core::process::GitRuntimeState; use gitcomet_core::services::{GitBackend, GitRepository}; use rustc_hash::FxHashMap as HashMap; use std::sync::{Arc, RwLock, mpsc}; @@ -40,6 +42,673 @@ fn selected_conflict_file_path( .and_then(|repo| repo.conflict_state.conflict_file_path.clone()) } +fn effect_requires_available_git(effect: &Effect) -> bool { + !matches!( + effect, + Effect::PersistSession { .. } | Effect::AbortCloneRepo { .. } + ) +} + +fn git_unavailable_error(runtime: &GitRuntimeState) -> Error { + Error::new(ErrorKind::Backend( + runtime + .unavailable_detail() + .unwrap_or("Git executable is unavailable.") + .to_string(), + )) +} + +fn send_unavailable_git_effect_result( + thread_state: &Arc>>, + msg_tx: &mpsc::Sender, + effect: Effect, + runtime: &GitRuntimeState, +) { + let send = |msg| util::send_or_log(msg_tx, msg); + + match effect { + Effect::PersistSession { .. } => {} + Effect::OpenRepo { repo_id, path } => { + send(Msg::Internal(crate::msg::InternalMsg::RepoOpenedErr { + repo_id, + spec: gitcomet_core::domain::RepoSpec { workdir: path }, + error: git_unavailable_error(runtime), + })) + } + Effect::LoadBranches { repo_id } => { + send(Msg::Internal(crate::msg::InternalMsg::BranchesLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadRemotes { repo_id } => { + send(Msg::Internal(crate::msg::InternalMsg::RemotesLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadRemoteBranches { repo_id } => send(Msg::Internal( + crate::msg::InternalMsg::RemoteBranchesLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::LoadWorktreeStatus { repo_id } => send(Msg::Internal( + crate::msg::InternalMsg::WorktreeStatusLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::LoadStagedStatus { repo_id } => { + send(Msg::Internal(crate::msg::InternalMsg::StagedStatusLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadStatus { repo_id } => { + send(Msg::Internal(crate::msg::InternalMsg::StatusLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadHeadBranch { repo_id } => { + send(Msg::Internal(crate::msg::InternalMsg::HeadBranchLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadUpstreamDivergence { repo_id } => send(Msg::Internal( + crate::msg::InternalMsg::UpstreamDivergenceLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::LoadLog { + repo_id, + scope, + cursor, + .. + } => send(Msg::Internal(crate::msg::InternalMsg::LogLoaded { + repo_id, + scope, + cursor, + result: Err(git_unavailable_error(runtime)), + })), + Effect::LoadTags { repo_id } => send(Msg::Internal(crate::msg::InternalMsg::TagsLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })), + Effect::LoadRemoteTags { repo_id } => { + send(Msg::Internal(crate::msg::InternalMsg::RemoteTagsLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadStashes { repo_id, .. } => { + send(Msg::Internal(crate::msg::InternalMsg::StashesLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadConflictFile { repo_id, path, .. } => { + send(Msg::Internal(crate::msg::InternalMsg::ConflictFileLoaded { + repo_id, + path, + result: Box::new(Err(git_unavailable_error(runtime))), + conflict_session: None, + })) + } + Effect::LoadReflog { repo_id, .. } => { + send(Msg::Internal(crate::msg::InternalMsg::ReflogLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::SaveWorktreeFile { + repo_id, + path, + stage, + .. + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::SaveWorktreeFile { path, stage }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::LoadFileHistory { repo_id, path, .. } => { + send(Msg::Internal(crate::msg::InternalMsg::FileHistoryLoaded { + repo_id, + path, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadBlame { repo_id, path, rev } => { + send(Msg::Internal(crate::msg::InternalMsg::BlameLoaded { + repo_id, + path, + rev, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadWorktrees { repo_id } => { + send(Msg::Internal(crate::msg::InternalMsg::WorktreesLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadSubmodules { repo_id } => { + send(Msg::Internal(crate::msg::InternalMsg::SubmodulesLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadRebaseAndMergeState { repo_id } => { + send(Msg::Internal(crate::msg::InternalMsg::RebaseStateLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })); + send(Msg::Internal( + crate::msg::InternalMsg::MergeCommitMessageLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + }, + )); + } + Effect::LoadRebaseState { repo_id } => { + send(Msg::Internal(crate::msg::InternalMsg::RebaseStateLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadMergeCommitMessage { repo_id } => send(Msg::Internal( + crate::msg::InternalMsg::MergeCommitMessageLoaded { + repo_id, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::LoadCommitDetails { repo_id, commit_id } => send(Msg::Internal( + crate::msg::InternalMsg::CommitDetailsLoaded { + repo_id, + commit_id, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::LoadDiff { repo_id, target } => { + send(Msg::Internal(crate::msg::InternalMsg::DiffLoaded { + repo_id, + target, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadDiffFile { repo_id, target } => { + send(Msg::Internal(crate::msg::InternalMsg::DiffFileLoaded { + repo_id, + target, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::LoadDiffPreviewTextFile { + repo_id, + target, + side, + } => send(Msg::Internal( + crate::msg::InternalMsg::DiffPreviewTextFileLoaded { + repo_id, + target, + side, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::LoadDiffFileImage { repo_id, target } => send(Msg::Internal( + crate::msg::InternalMsg::DiffFileImageLoaded { + repo_id, + target, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::LoadSelectedDiff { + repo_id, + load_patch_diff, + load_file_text, + preview_text_side, + load_file_image, + } => { + let Some(target) = selected_diff_target(thread_state, repo_id) else { + return; + }; + if load_file_image { + send(Msg::Internal( + crate::msg::InternalMsg::DiffFileImageLoaded { + repo_id, + target: target.clone(), + result: Err(git_unavailable_error(runtime)), + }, + )); + } + if let Some(side) = preview_text_side { + send(Msg::Internal( + crate::msg::InternalMsg::DiffPreviewTextFileLoaded { + repo_id, + target: target.clone(), + side, + result: Err(git_unavailable_error(runtime)), + }, + )); + } + if load_file_text { + send(Msg::Internal(crate::msg::InternalMsg::DiffFileLoaded { + repo_id, + target: target.clone(), + result: Err(git_unavailable_error(runtime)), + })); + } + if load_patch_diff { + send(Msg::Internal(crate::msg::InternalMsg::DiffLoaded { + repo_id, + target, + result: Err(git_unavailable_error(runtime)), + })); + } + } + Effect::LoadSelectedConflictFile { repo_id, .. } => { + let Some(path) = selected_conflict_file_path(thread_state, repo_id) else { + return; + }; + send(Msg::Internal(crate::msg::InternalMsg::ConflictFileLoaded { + repo_id, + path, + result: Box::new(Err(git_unavailable_error(runtime))), + conflict_session: None, + })); + } + Effect::CheckoutBranch { repo_id, .. } + | Effect::CheckoutRemoteBranch { repo_id, .. } + | Effect::CheckoutCommit { repo_id, .. } + | Effect::CherryPickCommit { repo_id, .. } + | Effect::RevertCommit { repo_id, .. } + | Effect::CreateBranch { repo_id, .. } + | Effect::CreateBranchAndCheckout { repo_id, .. } + | Effect::DeleteBranch { repo_id, .. } + | Effect::ForceDeleteBranch { repo_id, .. } + | Effect::StagePath { repo_id, .. } + | Effect::StagePaths { repo_id, .. } + | Effect::UnstagePath { repo_id, .. } + | Effect::UnstagePaths { repo_id, .. } + | Effect::DiscardWorktreeChangesPath { repo_id, .. } + | Effect::DiscardWorktreeChangesPaths { repo_id, .. } + | Effect::Stash { repo_id, .. } + | Effect::ApplyStash { repo_id, .. } + | Effect::PopStash { repo_id, .. } + | Effect::DropStash { repo_id, .. } => { + send(Msg::Internal(crate::msg::InternalMsg::RepoActionFinished { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::CloneRepo { url, dest, .. } => { + send(Msg::Internal(crate::msg::InternalMsg::CloneRepoFinished { + url, + dest, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::AbortCloneRepo { dest } => clone::schedule_abort_clone_repo(msg_tx.clone(), dest), + Effect::ExportPatch { + repo_id, + commit_id, + dest, + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::ExportPatch { commit_id, dest }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::ApplyPatch { repo_id, patch } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::ApplyPatch { patch }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::AddWorktree { + repo_id, + path, + reference, + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::AddWorktree { path, reference }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::RemoveWorktree { repo_id, path } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::RemoveWorktree { path }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::ForceRemoveWorktree { repo_id, path } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::ForceRemoveWorktree { path }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::AddSubmodule { + repo_id, url, path, .. + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::AddSubmodule { url, path }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::UpdateSubmodules { repo_id, .. } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::UpdateSubmodules, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::RemoveSubmodule { repo_id, path } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::RemoveSubmodule { path }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::StageHunk { repo_id, .. } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::StageHunk, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::UnstageHunk { repo_id, .. } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::UnstageHunk, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::ApplyWorktreePatch { + repo_id, reverse, .. + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::ApplyWorktreePatch { reverse }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::Commit { repo_id, .. } => { + send(Msg::Internal(crate::msg::InternalMsg::CommitFinished { + repo_id, + result: Err(git_unavailable_error(runtime)), + })) + } + Effect::CommitAmend { repo_id, .. } => send(Msg::Internal( + crate::msg::InternalMsg::CommitAmendFinished { + repo_id, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::FetchAll { + repo_id, prune: _, .. + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::FetchAll, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::PruneMergedBranches { repo_id } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::PruneMergedBranches, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::PruneLocalTags { repo_id } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::PruneLocalTags, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::Pull { repo_id, mode, .. } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::Pull { mode }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::PullBranch { + repo_id, + remote, + branch, + .. + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::PullBranch { remote, branch }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::MergeRef { repo_id, reference } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::MergeRef { reference }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::SquashRef { repo_id, reference } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::SquashRef { reference }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::Push { repo_id, .. } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::Push, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::ForcePush { repo_id, .. } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::ForcePush, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::PushSetUpstream { + repo_id, + remote, + branch, + .. + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::PushSetUpstream { remote, branch }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::SetUpstreamBranch { + repo_id, + branch, + upstream, + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::SetUpstreamBranch { branch, upstream }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::UnsetUpstreamBranch { repo_id, branch } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::UnsetUpstreamBranch { branch }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::DeleteRemoteBranch { + repo_id, + remote, + branch, + .. + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::DeleteRemoteBranch { remote, branch }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::Reset { + repo_id, + target, + mode, + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::Reset { mode, target }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::Rebase { repo_id, onto } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::Rebase { onto }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::RebaseContinue { repo_id } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::RebaseContinue, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::RebaseAbort { repo_id } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::RebaseAbort, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::MergeAbort { repo_id } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::MergeAbort, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::CreateTag { + repo_id, + name, + target, + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::CreateTag { name, target }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::DeleteTag { repo_id, name } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::DeleteTag { name }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::PushTag { + repo_id, + remote, + name, + .. + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::PushTag { remote, name }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::DeleteRemoteTag { + repo_id, + remote, + name, + .. + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::DeleteRemoteTag { remote, name }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::AddRemote { repo_id, name, url } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::AddRemote { name, url }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::RemoveRemote { repo_id, name } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::RemoveRemote { name }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::SetRemoteUrl { + repo_id, + name, + url, + kind, + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::SetRemoteUrl { name, url, kind }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::CheckoutConflictSide { + repo_id, + path, + side, + } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::CheckoutConflict { path, side }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::AcceptConflictDeletion { repo_id, path } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::AcceptConflictDeletion { path }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::CheckoutConflictBase { repo_id, path } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::CheckoutConflictBase { path }, + result: Err(git_unavailable_error(runtime)), + }, + )), + Effect::LaunchMergetool { repo_id, path } => send(Msg::Internal( + crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command: RepoCommandKind::LaunchMergetool { path }, + result: Err(git_unavailable_error(runtime)), + }, + )), + } +} + pub(super) fn schedule_effect( executor: &TaskExecutor, session_persist_executor: &TaskExecutor, @@ -49,6 +718,17 @@ pub(super) fn schedule_effect( msg_tx: mpsc::Sender, effect: Effect, ) { + if effect_requires_available_git(&effect) { + let runtime = { + let state = thread_state.read().unwrap_or_else(|e| e.into_inner()); + state.git_runtime.clone() + }; + if !runtime.is_available() { + send_unavailable_git_effect_result(thread_state, &msg_tx, effect, &runtime); + return; + } + } + match effect { Effect::PersistSession { repo_id, action } => { let state_snapshot = { @@ -80,6 +760,12 @@ pub(super) fn schedule_effect( Effect::LoadRemoteBranches { repo_id } => { repo_load::schedule_load_remote_branches(executor, repos, msg_tx, repo_id); } + Effect::LoadWorktreeStatus { repo_id } => { + repo_load::schedule_load_worktree_status(executor, repos, msg_tx, repo_id); + } + Effect::LoadStagedStatus { repo_id } => { + repo_load::schedule_load_staged_status(executor, repos, msg_tx, repo_id); + } Effect::LoadStatus { repo_id } => { repo_load::schedule_load_status(executor, repos, msg_tx, repo_id) } @@ -488,3 +1174,15 @@ pub(super) fn schedule_effect( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn abort_clone_repo_does_not_require_available_git() { + assert!(!effect_requires_available_git(&Effect::AbortCloneRepo { + dest: std::path::PathBuf::from("/tmp/example"), + })); + } +} diff --git a/crates/gitcomet-state/src/store/effects/clone.rs b/crates/gitcomet-state/src/store/effects/clone.rs index 3a9ce727..c31c94d6 100644 --- a/crates/gitcomet-state/src/store/effects/clone.rs +++ b/crates/gitcomet-state/src/store/effects/clone.rs @@ -9,7 +9,7 @@ use gitcomet_core::auth::{ take_staged_git_auth, }; use gitcomet_core::error::{Error, ErrorKind}; -use gitcomet_core::process::configure_background_command; +use gitcomet_core::process::git_command; use gitcomet_core::services::CommandOutput; use std::fs; use std::io::Read as _; @@ -621,8 +621,7 @@ pub(super) fn schedule_clone_repo( return; } - let mut cmd = Command::new("git"); - configure_background_command(&mut cmd); + let mut cmd = git_command(); cmd.arg("-c") .arg("color.ui=false") .arg("clone") diff --git a/crates/gitcomet-state/src/store/effects/repo_load.rs b/crates/gitcomet-state/src/store/effects/repo_load.rs index d4f45818..1f751004 100644 --- a/crates/gitcomet-state/src/store/effects/repo_load.rs +++ b/crates/gitcomet-state/src/store/effects/repo_load.rs @@ -199,6 +199,70 @@ pub(super) fn schedule_load_status( ); } +pub(super) fn schedule_load_worktree_status( + executor: &TaskExecutor, + repos: &RepoMap, + msg_tx: mpsc::Sender, + repo_id: RepoId, +) { + spawn_with_repo_or_else( + executor, + repos, + repo_id, + msg_tx, + move |repo, msg_tx| { + send_or_log( + &msg_tx, + Msg::Internal(crate::msg::InternalMsg::WorktreeStatusLoaded { + repo_id, + result: repo.worktree_status(), + }), + ); + }, + move |msg_tx| { + send_or_log( + &msg_tx, + Msg::Internal(crate::msg::InternalMsg::WorktreeStatusLoaded { + repo_id, + result: Err(missing_repo_error(repo_id)), + }), + ); + }, + ); +} + +pub(super) fn schedule_load_staged_status( + executor: &TaskExecutor, + repos: &RepoMap, + msg_tx: mpsc::Sender, + repo_id: RepoId, +) { + spawn_with_repo_or_else( + executor, + repos, + repo_id, + msg_tx, + move |repo, msg_tx| { + send_or_log( + &msg_tx, + Msg::Internal(crate::msg::InternalMsg::StagedStatusLoaded { + repo_id, + result: repo.staged_status(), + }), + ); + }, + move |msg_tx| { + send_or_log( + &msg_tx, + Msg::Internal(crate::msg::InternalMsg::StagedStatusLoaded { + repo_id, + result: Err(missing_repo_error(repo_id)), + }), + ); + }, + ); +} + pub(super) fn schedule_load_head_branch( executor: &TaskExecutor, repos: &RepoMap, diff --git a/crates/gitcomet-state/src/store/reducer.rs b/crates/gitcomet-state/src/store/reducer.rs index fe1e4886..f552ca51 100644 --- a/crates/gitcomet-state/src/store/reducer.rs +++ b/crates/gitcomet-state/src/store/reducer.rs @@ -59,6 +59,94 @@ fn begin_commit_action(state: &mut AppState, repo_id: RepoId) { } } +pub(crate) fn msg_requires_available_git(msg: &Msg) -> bool { + matches!( + msg, + Msg::OpenRepo(_) + | Msg::RestoreSession { .. } + | Msg::ReloadRepo { .. } + | Msg::RepoExternallyChanged { .. } + | Msg::SetHistoryScope { .. } + | Msg::LoadMoreHistory { .. } + | Msg::SelectCommit { .. } + | Msg::SelectDiff { .. } + | Msg::SelectConflictDiff { .. } + | Msg::LoadStashes { .. } + | Msg::LoadConflictFile { .. } + | Msg::LoadReflog { .. } + | Msg::LoadFileHistory { .. } + | Msg::LoadBlame { .. } + | Msg::LoadWorktrees { .. } + | Msg::LoadSubmodules { .. } + | Msg::LoadTags { .. } + | Msg::LoadRemoteTags { .. } + | Msg::RefreshBranches { .. } + | Msg::StageHunk { .. } + | Msg::UnstageHunk { .. } + | Msg::ApplyWorktreePatch { .. } + | Msg::CheckoutBranch { .. } + | Msg::CheckoutRemoteBranch { .. } + | Msg::CheckoutCommit { .. } + | Msg::CherryPickCommit { .. } + | Msg::RevertCommit { .. } + | Msg::CreateBranch { .. } + | Msg::CreateBranchAndCheckout { .. } + | Msg::DeleteBranch { .. } + | Msg::ForceDeleteBranch { .. } + | Msg::CloneRepo { .. } + | Msg::ExportPatch { .. } + | Msg::ApplyPatch { .. } + | Msg::AddWorktree { .. } + | Msg::RemoveWorktree { .. } + | Msg::ForceRemoveWorktree { .. } + | Msg::AddSubmodule { .. } + | Msg::UpdateSubmodules { .. } + | Msg::RemoveSubmodule { .. } + | Msg::StagePath { .. } + | Msg::StagePaths { .. } + | Msg::UnstagePath { .. } + | Msg::UnstagePaths { .. } + | Msg::DiscardWorktreeChangesPath { .. } + | Msg::DiscardWorktreeChangesPaths { .. } + | Msg::SaveWorktreeFile { .. } + | Msg::Commit { .. } + | Msg::CommitAmend { .. } + | Msg::FetchAll { .. } + | Msg::PruneMergedBranches { .. } + | Msg::PruneLocalTags { .. } + | Msg::Pull { .. } + | Msg::PullBranch { .. } + | Msg::MergeRef { .. } + | Msg::SquashRef { .. } + | Msg::Push { .. } + | Msg::ForcePush { .. } + | Msg::PushSetUpstream { .. } + | Msg::SetUpstreamBranch { .. } + | Msg::UnsetUpstreamBranch { .. } + | Msg::DeleteRemoteBranch { .. } + | Msg::Reset { .. } + | Msg::Rebase { .. } + | Msg::RebaseContinue { .. } + | Msg::RebaseAbort { .. } + | Msg::MergeAbort { .. } + | Msg::CreateTag { .. } + | Msg::DeleteTag { .. } + | Msg::PushTag { .. } + | Msg::DeleteRemoteTag { .. } + | Msg::AddRemote { .. } + | Msg::RemoveRemote { .. } + | Msg::SetRemoteUrl { .. } + | Msg::CheckoutConflictSide { .. } + | Msg::AcceptConflictDeletion { .. } + | Msg::CheckoutConflictBase { .. } + | Msg::LaunchMergetool { .. } + | Msg::Stash { .. } + | Msg::ApplyStash { .. } + | Msg::PopStash { .. } + | Msg::DropStash { .. } + ) +} + #[cfg(test)] pub(super) fn push_diagnostic( repo_state: &mut crate::model::RepoState, @@ -426,6 +514,10 @@ pub(super) fn reduce( state: &mut AppState, msg: Msg, ) -> Vec { + if msg_requires_available_git(&msg) && !state.git_runtime.is_available() { + return Vec::new(); + } + match msg { Msg::OpenRepo(path) => repo_management::open_repo(id_alloc, state, path), Msg::RestoreSession { @@ -458,6 +550,18 @@ pub(super) fn reduce( util::clear_staged_git_auth_env(); Vec::new() } + Msg::SetGitRuntimeState(runtime) => { + state.git_runtime = runtime; + Vec::new() + } + Msg::SetGitLogSettings { + show_history_tags, + tag_fetch_mode, + } => { + state.git_log_settings.show_history_tags = show_history_tags; + state.git_log_settings.tag_fetch_mode = tag_fetch_mode; + Vec::new() + } Msg::SetActiveRepo { repo_id } => repo_management::set_active_repo(state, repo_id), Msg::ReorderRepoTabs { repo_id, @@ -516,6 +620,8 @@ pub(super) fn reduce( Msg::LoadBlame { repo_id, path, rev } => effects::load_blame(state, repo_id, path, rev), Msg::LoadWorktrees { repo_id } => effects::load_worktrees(state, repo_id), Msg::LoadSubmodules { repo_id } => effects::load_submodules(state, repo_id), + Msg::LoadTags { repo_id } => effects::load_tags(state, repo_id), + Msg::LoadRemoteTags { repo_id } => effects::load_remote_tags(state, repo_id), Msg::RefreshBranches { repo_id } => effects::refresh_branches(state, repo_id), Msg::StageHunk { repo_id, patch } => { begin_local_action(state, repo_id); @@ -948,6 +1054,12 @@ pub(super) fn reduce( Msg::Internal(crate::msg::InternalMsg::RemoteBranchesLoaded { repo_id, result }) => { effects::remote_branches_loaded(state, repo_id, result) } + Msg::Internal(crate::msg::InternalMsg::WorktreeStatusLoaded { repo_id, result }) => { + effects::worktree_status_loaded(state, repo_id, result) + } + Msg::Internal(crate::msg::InternalMsg::StagedStatusLoaded { repo_id, result }) => { + effects::staged_status_loaded(state, repo_id, result) + } Msg::Internal(crate::msg::InternalMsg::StatusLoaded { repo_id, result }) => { effects::status_loaded(state, repo_id, result) } diff --git a/crates/gitcomet-state/src/store/reducer/actions_emit_effects.rs b/crates/gitcomet-state/src/store/reducer/actions_emit_effects.rs index 899aad49..b62e0330 100644 --- a/crates/gitcomet-state/src/store/reducer/actions_emit_effects.rs +++ b/crates/gitcomet-state/src/store/reducer/actions_emit_effects.rs @@ -763,7 +763,7 @@ pub(super) fn repo_command_finished( extra_effects.extend(diff_reload_effects(repo_state, repo_id, target)); } } - let mut effects = refresh_full_effects(repo_state); + let mut effects = refresh_full_effects(repo_state, state.git_log_settings); effects.extend(extra_effects); if clear_banner { clear_banner_error_for_repo(state, repo_id); diff --git a/crates/gitcomet-state/src/store/reducer/effects.rs b/crates/gitcomet-state/src/store/reducer/effects.rs index 19f36906..b1e1f522 100644 --- a/crates/gitcomet-state/src/store/reducer/effects.rs +++ b/crates/gitcomet-state/src/store/reducer/effects.rs @@ -113,14 +113,11 @@ fn build_conflict_session( file: &crate::model::ConflictFile, ) -> Option { // Look up the conflict kind from the repo's status entries. - let conflict_kind = match &repo_state.status { - Loadable::Ready(status) => status - .unstaged - .iter() - .find(|e| e.path == file.path && e.kind == FileStatusKind::Conflicted) - .and_then(|e| e.conflict), - _ => None, - }?; + let conflict_kind = repo_state + .worktree_status_entries()? + .iter() + .find(|e| e.path == file.path && e.kind == FileStatusKind::Conflicted) + .and_then(|e| e.conflict)?; let base = ConflictPayload::from_stage_parts(file.base_bytes.clone(), file.base.clone()); let ours = ConflictPayload::from_stage_parts(file.ours_bytes.clone(), file.ours.clone()); @@ -325,6 +322,39 @@ pub(super) fn refresh_branches(state: &mut AppState, repo_id: RepoId) -> Vec Vec { + let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) else { + return Vec::new(); + }; + if !matches!(repo_state.open, Loadable::Ready(())) { + return Vec::new(); + } + repo_state.set_tags(Loadable::Loading); + if repo_state.loads_in_flight.request(RepoLoadsInFlight::TAGS) { + vec![Effect::LoadTags { repo_id }] + } else { + Vec::new() + } +} + +pub(super) fn load_remote_tags(state: &mut AppState, repo_id: RepoId) -> Vec { + let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) else { + return Vec::new(); + }; + if !matches!(repo_state.open, Loadable::Ready(())) { + return Vec::new(); + } + repo_state.set_remote_tags(Loadable::Loading); + if repo_state + .loads_in_flight + .request(RepoLoadsInFlight::REMOTE_TAGS) + { + vec![Effect::LoadRemoteTags { repo_id }] + } else { + Vec::new() + } +} + pub(super) fn load_conflict_file( state: &mut AppState, repo_id: RepoId, @@ -526,34 +556,125 @@ pub(super) fn status_loaded( true } }; - // Replaying an unchanged status payload can self-sustain refresh loops when file-system - // events are produced by the status read itself (for example `.git/index` churn). - if repo_state.loads_in_flight.finish(RepoLoadsInFlight::STATUS) { - if should_replay_pending { - effects.push(Effect::LoadStatus { repo_id }); - } else { - // `finish` marks STATUS back as in-flight when there was pending work. We are - // intentionally dropping that replay request, so clear the in-flight bit too. - let _ = repo_state.loads_in_flight.finish(RepoLoadsInFlight::STATUS); + finish_status_lane_replay( + repo_state, + RepoLoadsInFlight::WORKTREE_STATUS, + repo_id, + should_replay_pending, + Effect::LoadWorktreeStatus { repo_id }, + &mut effects, + ); + finish_status_lane_replay( + repo_state, + RepoLoadsInFlight::STAGED_STATUS, + repo_id, + should_replay_pending, + Effect::LoadStagedStatus { repo_id }, + &mut effects, + ); + } + effects +} + +pub(super) fn worktree_status_loaded( + state: &mut AppState, + repo_id: RepoId, + result: std::result::Result, Error>, +) -> Vec { + let mut effects = Vec::new(); + if let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) { + let should_replay_pending = match result { + Ok(next) => { + let status_unchanged = matches!(&repo_state.worktree_status, Loadable::Ready(prev) if prev.as_slice() == next.as_slice()); + if !status_unchanged { + repo_state.set_worktree_status(Loadable::Ready(next)); + } + clear_resolved_conflict_context(repo_state); + !status_unchanged } - } + Err(e) => { + push_diagnostic(repo_state, DiagnosticKind::Error, e.to_string()); + repo_state.set_worktree_status(Loadable::Error(e.to_string())); + true + } + }; + finish_status_lane_replay( + repo_state, + RepoLoadsInFlight::WORKTREE_STATUS, + repo_id, + should_replay_pending, + Effect::LoadWorktreeStatus { repo_id }, + &mut effects, + ); + } + effects +} + +pub(super) fn staged_status_loaded( + state: &mut AppState, + repo_id: RepoId, + result: std::result::Result, Error>, +) -> Vec { + let mut effects = Vec::new(); + if let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) { + let should_replay_pending = match result { + Ok(next) => { + let status_unchanged = matches!(&repo_state.staged_status, Loadable::Ready(prev) if prev.as_slice() == next.as_slice()); + if !status_unchanged { + repo_state.set_staged_status(Loadable::Ready(next)); + } + !status_unchanged + } + Err(e) => { + push_diagnostic(repo_state, DiagnosticKind::Error, e.to_string()); + repo_state.set_staged_status(Loadable::Error(e.to_string())); + true + } + }; + finish_status_lane_replay( + repo_state, + RepoLoadsInFlight::STAGED_STATUS, + repo_id, + should_replay_pending, + Effect::LoadStagedStatus { repo_id }, + &mut effects, + ); } effects } +fn finish_status_lane_replay( + repo_state: &mut crate::model::RepoState, + flag: u32, + _repo_id: RepoId, + should_replay_pending: bool, + replay_effect: Effect, + effects: &mut Vec, +) { + // Replaying an unchanged status payload can self-sustain refresh loops when file-system + // events are produced by the status read itself (for example `.git/index` churn). + if repo_state.loads_in_flight.finish(flag) { + if should_replay_pending { + effects.push(replay_effect); + } else { + // `finish` marks the flag back as in-flight when there was pending work. We are + // intentionally dropping that replay request, so clear the in-flight bit too. + let _ = repo_state.loads_in_flight.finish(flag); + } + } +} + /// Clear conflict-file/session state when the tracked conflict path is no longer /// present as an unresolved conflict in status. fn clear_resolved_conflict_context(repo_state: &mut crate::model::RepoState) { let Some(conflict_path) = repo_state.conflict_state.conflict_file_path.as_ref() else { return; }; - let still_conflicted = match &repo_state.status { - Loadable::Ready(status) => status - .unstaged + let still_conflicted = repo_state.worktree_status_entries().is_none_or(|status| { + status .iter() - .any(|entry| entry.path == *conflict_path && entry.kind == FileStatusKind::Conflicted), - _ => true, - }; + .any(|entry| entry.path == *conflict_path && entry.kind == FileStatusKind::Conflicted) + }); if still_conflicted { return; } @@ -1687,12 +1808,12 @@ mod tests { ))); repo.set_conflict_hide_resolved(true); } - mark_pending(&mut state, repo_id, RepoLoadsInFlight::STATUS); + mark_pending(&mut state, repo_id, RepoLoadsInFlight::WORKTREE_STATUS); let effects = status_loaded(&mut state, repo_id, Ok(RepoStatus::default())); assert_eq!(effects.len(), 1); assert!(matches!( effects[0], - Effect::LoadStatus { repo_id: rid } if rid == repo_id + Effect::LoadWorktreeStatus { repo_id: rid } if rid == repo_id )); { let repo = repo_mut(&mut state, repo_id); diff --git a/crates/gitcomet-state/src/store/reducer/external_and_history.rs b/crates/gitcomet-state/src/store/reducer/external_and_history.rs index c298826e..c43c9b34 100644 --- a/crates/gitcomet-state/src/store/reducer/external_and_history.rs +++ b/crates/gitcomet-state/src/store/reducer/external_and_history.rs @@ -1,12 +1,13 @@ use super::util::{ - SelectedConflictTarget, clear_banner_error_for_repo, diff_reload_effects, - handle_session_persist_result, push_diagnostic, refresh_full_effects, refresh_primary_effects, - selected_conflict_target, start_conflict_target_reload, start_current_conflict_target_reload, + SelectedConflictTarget, append_requested_status_refresh_effects, clear_banner_error_for_repo, + diff_reload_effects, handle_session_persist_result, push_diagnostic, refresh_full_effects, + refresh_primary_effects, selected_conflict_target, start_conflict_target_reload, + start_current_conflict_target_reload, }; use crate::model::{AppState, DiagnosticKind, Loadable, RepoLoadsInFlight}; use crate::msg::{Effect, RepoExternalChange}; use crate::session; -use gitcomet_core::domain::{DiffTarget, LogCursor, LogPage, LogScope}; +use gitcomet_core::domain::{DiffArea, DiffTarget, LogCursor, LogPage, LogScope}; use gitcomet_core::error::Error; use std::sync::Arc; @@ -52,6 +53,7 @@ fn reserve_initial_paginated_log_append_slack(commits: &mut Vec) { } pub(super) fn reload_repo(state: &mut AppState, repo_id: crate::model::RepoId) -> Vec { + let git_log_settings = state.git_log_settings; let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) else { return Vec::new(); }; @@ -59,8 +61,12 @@ pub(super) fn reload_repo(state: &mut AppState, repo_id: crate::model::RepoId) - repo_state.set_head_branch(Loadable::Loading); repo_state.set_detached_head_commit(None); repo_state.set_branches(Loadable::Loading); - repo_state.set_tags(Loadable::Loading); - repo_state.set_remote_tags(Loadable::Loading); + if git_log_settings.show_history_tags && git_log_settings.auto_fetch_tags_on_repo_activation() { + repo_state.set_tags(Loadable::Loading); + } else { + repo_state.set_tags(Loadable::NotLoaded); + } + repo_state.set_remote_tags(Loadable::NotLoaded); repo_state.set_remotes(Loadable::Loading); repo_state.set_remote_branches(Loadable::Loading); repo_state.set_status(Loadable::Loading); @@ -80,7 +86,7 @@ pub(super) fn reload_repo(state: &mut AppState, repo_id: crate::model::RepoId) - repo_state.set_selected_commit(None); repo_state.set_commit_details(Loadable::NotLoaded); - refresh_full_effects(repo_state) + refresh_full_effects(repo_state, git_log_settings) } pub(super) fn repo_externally_changed( @@ -93,36 +99,52 @@ pub(super) fn repo_externally_changed( }; // Coalesce refreshes while a refresh is already in flight. - let (mut effects, should_reload_diff) = match change { - RepoExternalChange::Worktree => { - if repo_state - .loads_in_flight - .request(RepoLoadsInFlight::STATUS) - { - (vec![Effect::LoadStatus { repo_id }], true) - } else { - (Vec::new(), false) - } + let mut effects = if change.git_state { + let mut effects = refresh_primary_effects(repo_state); + if repo_state + .loads_in_flight + .request(RepoLoadsInFlight::BRANCHES) + { + effects.push(Effect::LoadBranches { repo_id }); + } + if repo_state + .loads_in_flight + .request(RepoLoadsInFlight::REMOTE_BRANCHES) + { + effects.push(Effect::LoadRemoteBranches { repo_id }); } - RepoExternalChange::GitState | RepoExternalChange::Both => { - let mut effects = refresh_primary_effects(repo_state); - if repo_state + effects + } else { + let mut effects = Vec::new(); + if change.worktree && change.index { + append_requested_status_refresh_effects(repo_state, &mut effects); + } else if change.worktree + && repo_state .loads_in_flight - .request(RepoLoadsInFlight::BRANCHES) - { - effects.push(Effect::LoadBranches { repo_id }); - } - if repo_state + .request(RepoLoadsInFlight::WORKTREE_STATUS) + { + effects.push(Effect::LoadWorktreeStatus { repo_id }); + } else if change.index + && repo_state .loads_in_flight - .request(RepoLoadsInFlight::REMOTE_BRANCHES) - { - effects.push(Effect::LoadRemoteBranches { repo_id }); - } - let should_reload_diff = !effects.is_empty(); - (effects, should_reload_diff) + .request(RepoLoadsInFlight::STAGED_STATUS) + { + effects.push(Effect::LoadStagedStatus { repo_id }); } + effects }; + let should_reload_diff = repo_state + .diff_state + .diff_target + .as_ref() + .is_some_and(|target| match target { + DiffTarget::WorkingTree { area, .. } => { + change.git_state || change.index || (*area == DiffArea::Unstaged && change.worktree) + } + DiffTarget::Commit { .. } => false, + }); + if should_reload_diff && let Some(target) = repo_state.diff_state.diff_target.clone() && matches!(target, DiffTarget::WorkingTree { .. }) diff --git a/crates/gitcomet-state/src/store/reducer/repo_management.rs b/crates/gitcomet-state/src/store/reducer/repo_management.rs index 58e1507f..659817de 100644 --- a/crates/gitcomet-state/src/store/reducer/repo_management.rs +++ b/crates/gitcomet-state/src/store/reducer/repo_management.rs @@ -9,7 +9,8 @@ use super::util::{ }; use crate::model::{ AppNotificationKind, AppState, CloneOpState, CloneOpStatus, CloneProgressMeter, - CloneProgressStage, DiagnosticKind, Loadable, RepoId, RepoLoadsInFlight, RepoState, + CloneProgressStage, DiagnosticKind, GitLogSettings, Loadable, RepoId, RepoLoadsInFlight, + RepoState, }; use crate::msg::Effect; use crate::session; @@ -31,10 +32,14 @@ pub(crate) const REORDER_REPO_TABS_INLINE_EFFECT_CAPACITY: usize = 1; pub(crate) type ReorderRepoTabsEffects = SmallVec<[Effect; REORDER_REPO_TABS_INLINE_EFFECT_CAPACITY]>; -fn repo_switch_secondary_metadata_ready(repo_state: &RepoState) -> bool { +fn repo_switch_secondary_metadata_ready( + repo_state: &RepoState, + git_log_settings: GitLogSettings, +) -> bool { matches!(repo_state.branches, Loadable::Ready(_)) - && matches!(repo_state.tags, Loadable::Ready(_)) - && matches!(repo_state.remote_tags, Loadable::Ready(_)) + && (!git_log_settings.show_history_tags + || !git_log_settings.auto_fetch_tags_on_repo_activation() + || matches!(repo_state.tags, Loadable::Ready(_))) && matches!(repo_state.remotes, Loadable::Ready(_)) && matches!(repo_state.remote_branches, Loadable::Ready(_)) && matches!(repo_state.stashes, Loadable::Ready(_)) @@ -42,8 +47,12 @@ fn repo_switch_secondary_metadata_ready(repo_state: &RepoState) -> bool { && matches!(repo_state.merge_commit_message, Loadable::Ready(_)) } -fn repo_switch_can_use_primary_refresh(repo_state: &RepoState, now: SystemTime) -> bool { - repo_switch_secondary_metadata_ready(repo_state) +fn repo_switch_can_use_primary_refresh( + repo_state: &RepoState, + git_log_settings: GitLogSettings, + now: SystemTime, +) -> bool { + repo_switch_secondary_metadata_ready(repo_state, git_log_settings) && repo_state .last_active_at .and_then(|last_active_at| now.duration_since(last_active_at).ok()) @@ -278,6 +287,7 @@ pub(super) fn fill_set_active_repo_inline( state.active_repo = Some(repo_id); let persist_effect = changed .then(|| persist_session_effect(state, Some(repo_id), "switching active repository")); + let git_log_settings = state.git_log_settings; let repo_state = &mut state.repos[repo_ix]; @@ -292,7 +302,8 @@ pub(super) fn fill_set_active_repo_inline( return; } - let use_full_refresh = changed && !repo_switch_can_use_primary_refresh(repo_state, now); + let use_full_refresh = + changed && !repo_switch_can_use_primary_refresh(repo_state, git_log_settings, now); repo_state.last_active_at = Some(now); // Reload the selected diff when switching repos; steady-state refreshes rely on the @@ -333,7 +344,7 @@ pub(super) fn fill_set_active_repo_inline( base_effect_capacity + extra_effect_capacity <= SET_ACTIVE_REPO_INLINE_EFFECT_CAPACITY ); if use_full_refresh { - append_refresh_full_effects(repo_state, effects); + append_refresh_full_effects(repo_state, git_log_settings, effects); } else { append_refresh_primary_effects(repo_state, effects); } @@ -610,6 +621,7 @@ pub(super) fn repo_opened_ok( repo: Arc, ) -> Vec { repos.insert(repo_id, repo); + let git_log_settings = state.git_log_settings; let spec = RepoSpec { workdir: normalize_repo_path(spec.workdir), @@ -623,8 +635,14 @@ pub(super) fn repo_opened_ok( repo_state.set_detached_head_commit(None); repo_state.set_upstream_divergence(Loadable::Loading); repo_state.set_branches(Loadable::Loading); - repo_state.set_tags(Loadable::Loading); - repo_state.set_remote_tags(Loadable::Loading); + if git_log_settings.show_history_tags + && git_log_settings.auto_fetch_tags_on_repo_activation() + { + repo_state.set_tags(Loadable::Loading); + } else { + repo_state.set_tags(Loadable::NotLoaded); + } + repo_state.set_remote_tags(Loadable::NotLoaded); repo_state.set_remotes(Loadable::Loading); repo_state.set_remote_branches(Loadable::Loading); repo_state.set_status(Loadable::Loading); @@ -659,7 +677,7 @@ pub(super) fn repo_opened_ok( let should_refresh_worktrees = state.active_repo == Some(repo_id); if let Some(repo_state) = state.repos.iter_mut().find(|r| r.id == repo_id) { - let mut effects = refresh_full_effects(repo_state); + let mut effects = refresh_full_effects(repo_state, git_log_settings); if should_refresh_worktrees && repo_state .loads_in_flight diff --git a/crates/gitcomet-state/src/store/reducer/util.rs b/crates/gitcomet-state/src/store/reducer/util.rs index 5721f67c..e6b03010 100644 --- a/crates/gitcomet-state/src/store/reducer/util.rs +++ b/crates/gitcomet-state/src/store/reducer/util.rs @@ -1,7 +1,7 @@ use crate::model::{ AppNotification, AppNotificationKind, AppState, AuthPromptKind, CommandLogEntry, - ConflictFileLoadMode, DiagnosticEntry, DiagnosticKind, Loadable, RepoId, RepoLoadsInFlight, - RepoState, + ConflictFileLoadMode, DiagnosticEntry, DiagnosticKind, GitLogSettings, Loadable, RepoId, + RepoLoadsInFlight, RepoState, }; use crate::msg::{ConflictAutosolveMode, ConflictAutosolveStats, Effect, RepoCommandKind}; #[cfg(test)] @@ -20,8 +20,8 @@ use std::time::SystemTime; pub(super) const DEFAULT_LOG_PAGE_SIZE: usize = 200; const CONFLICT_RELOAD_EFFECT_COUNT: usize = 1; const DIFF_RELOAD_MAX_EFFECTS: usize = 3; -const PRIMARY_REFRESH_MAX_EFFECTS: usize = 5; -const FULL_REFRESH_MAX_EFFECTS: usize = 10; +const PRIMARY_REFRESH_MAX_EFFECTS: usize = 6; +const FULL_REFRESH_MAX_EFFECTS: usize = 11; pub(super) trait EffectAccumulator { fn push_effect(&mut self, effect: Effect); @@ -131,13 +131,9 @@ pub(super) fn diff_target_is_svg(target: &DiffTarget) -> bool { fn diff_target_is_preview_only(repo_state: &RepoState, target: &DiffTarget) -> bool { match target { DiffTarget::WorkingTree { path, area } => { - let Loadable::Ready(status) = &repo_state.status else { + let Some(entries) = repo_state.status_entries_for_area(*area) else { return false; }; - let entries = match area { - DiffArea::Unstaged => status.unstaged.as_slice(), - DiffArea::Staged => status.staged.as_slice(), - }; entries.iter().any(|entry| { entry.path == *path @@ -173,13 +169,7 @@ fn diff_target_preview_text_side( ) -> Option { match target { DiffTarget::WorkingTree { path, area } => { - let Loadable::Ready(status) = &repo_state.status else { - return None; - }; - let entries = match area { - DiffArea::Unstaged => status.unstaged.as_slice(), - DiffArea::Staged => status.staged.as_slice(), - }; + let entries = repo_state.status_entries_for_area(*area)?; entries.iter().find_map(|entry| { (entry.path == *path).then_some(match entry.kind { @@ -291,11 +281,8 @@ pub(super) fn selected_conflict_target<'a>( return None; } - let Loadable::Ready(status) = &repo_state.status else { - return None; - }; - status - .unstaged + repo_state + .worktree_status_entries()? .iter() .find(|entry| entry.path == *path && entry.kind == FileStatusKind::Conflicted) .map(|_| SelectedConflictTarget::Path(path.as_path())) @@ -449,6 +436,30 @@ pub(super) fn refresh_primary_effect_capacity() -> usize { PRIMARY_REFRESH_MAX_EFFECTS } +fn should_auto_fetch_history_tags(git_log_settings: GitLogSettings) -> bool { + git_log_settings.show_history_tags && git_log_settings.auto_fetch_tags_on_repo_activation() +} + +pub(super) fn append_requested_status_refresh_effects( + repo_state: &mut RepoState, + effects: &mut impl EffectAccumulator, +) { + let repo_id = repo_state.id; + let load_worktree = repo_state + .loads_in_flight + .request(RepoLoadsInFlight::WORKTREE_STATUS); + let load_staged = repo_state + .loads_in_flight + .request(RepoLoadsInFlight::STAGED_STATUS); + + match (load_worktree, load_staged) { + (true, true) => effects.push_effect(Effect::LoadStatus { repo_id }), + (true, false) => effects.push_effect(Effect::LoadWorktreeStatus { repo_id }), + (false, true) => effects.push_effect(Effect::LoadStagedStatus { repo_id }), + (false, false) => {} + } +} + fn push_rebase_and_merge_refresh_effect(effects: &mut impl EffectAccumulator, repo_id: RepoId) { effects.push_effect(Effect::LoadRebaseAndMergeState { repo_id }); } @@ -514,12 +525,7 @@ pub(super) fn append_refresh_primary_effects( effects.push_effect(Effect::LoadUpstreamDivergence { repo_id }); } append_requested_rebase_and_merge_refresh_effects(repo_state, effects); - if repo_state - .loads_in_flight - .request(RepoLoadsInFlight::STATUS) - { - effects.push_effect(Effect::LoadStatus { repo_id }); - } + append_requested_status_refresh_effects(repo_state, effects); if repo_state .loads_in_flight .request_log(scope, DEFAULT_LOG_PAGE_SIZE, None) @@ -540,14 +546,18 @@ pub(super) fn refresh_full_effect_capacity() -> usize { FULL_REFRESH_MAX_EFFECTS } -pub(super) fn refresh_full_effects(repo_state: &mut RepoState) -> Vec { +pub(super) fn refresh_full_effects( + repo_state: &mut RepoState, + git_log_settings: GitLogSettings, +) -> Vec { let mut effects = Vec::with_capacity(refresh_full_effect_capacity()); - append_refresh_full_effects(repo_state, &mut effects); + append_refresh_full_effects(repo_state, git_log_settings, &mut effects); effects } pub(super) fn append_refresh_full_effects( repo_state: &mut RepoState, + git_log_settings: GitLogSettings, effects: &mut impl EffectAccumulator, ) { let repo_id = repo_state.id; @@ -566,12 +576,7 @@ pub(super) fn append_refresh_full_effects( { effects.push_effect(Effect::LoadUpstreamDivergence { repo_id }); } - if repo_state - .loads_in_flight - .request(RepoLoadsInFlight::STATUS) - { - effects.push_effect(Effect::LoadStatus { repo_id }); - } + append_requested_status_refresh_effects(repo_state, effects); if repo_state.loads_in_flight.request_log( repo_state.history_state.history_scope, DEFAULT_LOG_PAGE_SIZE, @@ -591,14 +596,10 @@ pub(super) fn append_refresh_full_effects( { effects.push_effect(Effect::LoadBranches { repo_id }); } - if repo_state.loads_in_flight.request(RepoLoadsInFlight::TAGS) { - effects.push_effect(Effect::LoadTags { repo_id }); - } - if repo_state - .loads_in_flight - .request(RepoLoadsInFlight::REMOTE_TAGS) + if should_auto_fetch_history_tags(git_log_settings) + && repo_state.loads_in_flight.request(RepoLoadsInFlight::TAGS) { - effects.push_effect(Effect::LoadRemoteTags { repo_id }); + effects.push_effect(Effect::LoadTags { repo_id }); } if repo_state .loads_in_flight @@ -1375,6 +1376,11 @@ mod tests { assert_eq!(primary_effects.len(), 5); assert!(!primary.log_loading_more); assert!(matches!(primary_effects[0], Effect::LoadHeadBranch { .. })); + assert!( + primary_effects + .iter() + .any(|effect| matches!(effect, Effect::LoadStatus { .. })) + ); assert!(matches!( primary_effects[4], Effect::LoadLog { @@ -1382,6 +1388,15 @@ mod tests { .. } )); + assert!( + !primary_effects.iter().any(|effect| { + matches!( + effect, + Effect::LoadWorktreeStatus { .. } | Effect::LoadStagedStatus { .. } + ) + }), + "primary refresh should coalesce staged and worktree status into LoadStatus" + ); assert!( primary_effects .iter() @@ -1390,13 +1405,33 @@ mod tests { let mut full = repo_state(2); full.set_log_loading_more(true); - let full_effects = refresh_full_effects(&mut full); - assert_eq!(full_effects.len(), 10); + let full_effects = refresh_full_effects(&mut full, GitLogSettings::default()); + assert_eq!(full_effects.len(), 9); assert!(!full.log_loading_more); assert!( full_effects .iter() - .any(|effect| matches!(effect, Effect::LoadRemoteTags { .. })) + .any(|effect| matches!(effect, Effect::LoadStatus { .. })) + ); + assert!( + !full_effects.iter().any(|effect| { + matches!( + effect, + Effect::LoadWorktreeStatus { .. } | Effect::LoadStagedStatus { .. } + ) + }), + "full refresh should coalesce staged and worktree status into LoadStatus" + ); + assert!( + full_effects + .iter() + .any(|effect| matches!(effect, Effect::LoadTags { .. })) + ); + assert!( + !full_effects + .iter() + .any(|effect| matches!(effect, Effect::LoadRemoteTags { .. })), + "remote tags should lazy-load from tag-specific UI instead of refresh_full_effects" ); assert!( !full_effects diff --git a/crates/gitcomet-state/src/store/repo_monitor.rs b/crates/gitcomet-state/src/store/repo_monitor.rs index 1e6e3121..9c292f9f 100644 --- a/crates/gitcomet-state/src/store/repo_monitor.rs +++ b/crates/gitcomet-state/src/store/repo_monitor.rs @@ -641,7 +641,7 @@ fn repo_monitor_thread( } Ok(MonitorMsg::Event(Err(_))) => { let now = Instant::now(); - if let Some(to_flush) = debouncer.push(RepoExternalChange::Both, now) { + if let Some(to_flush) = debouncer.push(RepoExternalChange::all(), now) { flush(to_flush); } } @@ -686,12 +686,10 @@ fn resolve_git_dir(workdir: &Path) -> Option { } fn merge_change(a: RepoExternalChange, b: RepoExternalChange) -> RepoExternalChange { - use RepoExternalChange::*; - match (a, b) { - (Both, _) | (_, Both) => Both, - (Worktree, GitState) | (GitState, Worktree) => Both, - (Worktree, Worktree) => Worktree, - (GitState, GitState) => GitState, + RepoExternalChange { + worktree: a.worktree || b.worktree, + index: a.index || b.index, + git_state: a.git_state || b.git_state, } } @@ -707,7 +705,7 @@ fn classify_repo_event( // If notify indicates a rescan is needed, assume anything could have changed. if event.need_rescan() { - return Some(RepoExternalChange::Both); + return Some(RepoExternalChange::all()); } // Update ignore rules if the ignore config itself changes. @@ -717,15 +715,16 @@ fn classify_repo_event( .any(|p| is_gitignore_config_path(workdir, git_dir, p)) { *gitignore = GitignoreRules::load(workdir); - return Some(RepoExternalChange::Worktree); + return Some(RepoExternalChange::worktree()); } if event.paths.is_empty() { - return Some(RepoExternalChange::Both); + return Some(RepoExternalChange::all()); } let mut saw_worktree = false; - let mut saw_git = false; + let mut saw_index = false; + let mut saw_git_state = false; let is_dir_hint = path_dir_hint(event); for path in &event.paths { @@ -733,12 +732,10 @@ fn classify_repo_event( continue; } if is_git_related_path(workdir, git_dir, path) { - // Treat `.git/index` updates like worktree changes: they typically reflect staging - // operations and should not trigger branch list refreshes. if is_git_index_path(workdir, git_dir, path) { - saw_worktree = true; + saw_index = true; } else { - saw_git = true; + saw_git_state = true; } } else { if is_ignored_worktree_path_with_hint(workdir, gitignore, path, is_dir_hint) { @@ -746,18 +743,14 @@ fn classify_repo_event( } saw_worktree = true; } - if saw_git && saw_worktree { - return Some(RepoExternalChange::Both); - } } - if saw_git { - Some(RepoExternalChange::GitState) - } else if saw_worktree { - Some(RepoExternalChange::Worktree) - } else { - None - } + let change = RepoExternalChange { + worktree: saw_worktree, + index: saw_index, + git_state: saw_git_state, + }; + (!change.is_empty()).then_some(change) } fn is_git_related_path(workdir: &Path, git_dir: Option<&Path>, path: &Path) -> bool { @@ -975,11 +968,19 @@ mod tests { fn merge_change_coalesces_to_both() { assert_eq!( merge_change(RepoExternalChange::Worktree, RepoExternalChange::GitState), - RepoExternalChange::Both + RepoExternalChange { + worktree: true, + index: false, + git_state: true, + } ); assert_eq!( merge_change(RepoExternalChange::GitState, RepoExternalChange::Worktree), - RepoExternalChange::Both + RepoExternalChange { + worktree: true, + index: false, + git_state: true, + } ); assert_eq!( merge_change(RepoExternalChange::Both, RepoExternalChange::Worktree), @@ -1009,7 +1010,7 @@ mod tests { &mut GitignoreRules::default(), &event ), - Some(RepoExternalChange::Worktree) + Some(RepoExternalChange::Index) ); let event = notify::Event { @@ -1039,7 +1040,11 @@ mod tests { &mut GitignoreRules::default(), &event ), - Some(RepoExternalChange::Both) + Some(RepoExternalChange { + worktree: true, + index: false, + git_state: true, + }) ); } diff --git a/crates/gitcomet-state/src/store/tests.rs b/crates/gitcomet-state/src/store/tests.rs index aed28deb..0a8ac0fe 100644 --- a/crates/gitcomet-state/src/store/tests.rs +++ b/crates/gitcomet-state/src/store/tests.rs @@ -7,7 +7,11 @@ use gitcomet_core::domain::{ }; use gitcomet_core::error::{Error, ErrorKind}; use gitcomet_core::path_utils::canonicalize_or_original; +use gitcomet_core::process::{ + GitExecutablePreference, current_git_executable_preference, install_git_executable_preference, +}; use gitcomet_core::services::{CommandOutput, PullMode, Result}; +use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; @@ -204,6 +208,103 @@ pub(crate) fn staged_auth_test_lock() -> MutexGuard<'static, ()> { .unwrap_or_else(|e| e.into_inner()) } +fn git_runtime_store_test_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|e| e.into_inner()) +} + +struct GitRuntimePreferenceResetGuard { + original: GitExecutablePreference, +} + +impl GitRuntimePreferenceResetGuard { + fn install(preference: GitExecutablePreference) -> Self { + let original = current_git_executable_preference(); + let _ = install_git_executable_preference(preference); + Self { original } + } +} + +impl Drop for GitRuntimePreferenceResetGuard { + fn drop(&mut self) { + let _ = install_git_executable_preference(self.original.clone()); + } +} + +#[cfg(unix)] +fn write_git_runtime_probe_script(script_path: &Path, probe_log: &Path) { + use std::os::unix::fs::PermissionsExt as _; + + fs::write( + script_path, + format!( + "#!/bin/sh\nprintf 'probe\\n' >> '{}'\nprintf 'git version 9.9.9-test\\n'\n", + probe_log.display() + ), + ) + .expect("write git runtime probe script"); + let mut permissions = fs::metadata(script_path) + .expect("git runtime probe script metadata") + .permissions(); + permissions.set_mode(0o700); + fs::set_permissions(script_path, permissions) + .expect("set git runtime probe script permissions"); +} + +#[cfg(windows)] +fn write_git_runtime_probe_script(script_path: &Path, probe_log: &Path) { + fs::write( + script_path, + format!( + "@echo off\r\necho probe>>\"{}\"\r\necho git version 9.9.9-test\r\n", + probe_log.display() + ), + ) + .expect("write git runtime probe script"); +} + +fn git_runtime_probe_count(probe_log: &Path) -> usize { + fs::read_to_string(probe_log) + .unwrap_or_default() + .lines() + .count() +} + +fn has_worktree_status_effect(effects: &[Effect], repo_id: RepoId) -> bool { + effects.iter().any(|effect| { + matches!( + effect, + Effect::LoadWorktreeStatus { repo_id: candidate } if *candidate == repo_id + ) + }) +} + +fn has_staged_status_effect(effects: &[Effect], repo_id: RepoId) -> bool { + effects.iter().any(|effect| { + matches!( + effect, + Effect::LoadStagedStatus { repo_id: candidate } if *candidate == repo_id + ) + }) +} + +fn has_combined_status_effect(effects: &[Effect], repo_id: RepoId) -> bool { + effects.iter().any(|effect| { + matches!( + effect, + Effect::LoadStatus { repo_id: candidate } if *candidate == repo_id + ) + }) +} + +fn has_status_refresh_effects(effects: &[Effect], repo_id: RepoId) -> bool { + has_combined_status_effect(effects, repo_id) + || (has_worktree_status_effect(effects, repo_id) + && has_staged_status_effect(effects, repo_id)) +} + #[test] fn app_store_clone_dispatches_restore_and_close_paths() { let backend: Arc = Arc::new(FailingBackend); @@ -226,6 +327,40 @@ fn app_store_clone_dispatches_restore_and_close_paths() { assert_eq!(snapshot.active_repo, None); } +#[test] +fn app_store_dispatch_does_not_reprobe_git_runtime_for_git_messages() { + let _lock = git_runtime_store_test_lock(); + let temp = tempfile::tempdir().expect("create tempdir for git runtime probe"); + let probe_log = temp.path().join("git-runtime-probes.log"); + #[cfg(unix)] + let script_path = temp.path().join("git"); + #[cfg(windows)] + let script_path = temp.path().join("git.cmd"); + write_git_runtime_probe_script(&script_path, &probe_log); + + let _restore = + GitRuntimePreferenceResetGuard::install(GitExecutablePreference::Custom(script_path)); + + let backend: Arc = Arc::new(FailingBackend); + let (store, _event_rx) = AppStore::new(backend); + + assert_eq!( + git_runtime_probe_count(&probe_log), + 1, + "installing the custom runtime should probe exactly once" + ); + + store.dispatch(Msg::ReloadRepo { + repo_id: RepoId(999), + }); + + assert_eq!( + git_runtime_probe_count(&probe_log), + 1, + "dispatch should not re-run `git --version` for regular Git-backed messages" + ); +} + #[test] fn app_store_open_repo_effect_propagates_open_error_into_state() { let backend: Arc = Arc::new(FailingBackend); diff --git a/crates/gitcomet-state/src/store/tests/effects.rs b/crates/gitcomet-state/src/store/tests/effects.rs index 0feb4760..832dced8 100644 --- a/crates/gitcomet-state/src/store/tests/effects.rs +++ b/crates/gitcomet-state/src/store/tests/effects.rs @@ -75,6 +75,66 @@ fn schedule_effect_for_test( ); } +#[test] +fn unavailable_git_effect_emits_synthetic_repo_command_error() { + struct Backend; + impl GitBackend for Backend { + fn open(&self, _path: &Path) -> std::result::Result, Error> { + Err(Error::new(ErrorKind::Unsupported("test backend"))) + } + } + + let executor = super::executor::TaskExecutor::new(1); + let backend: Arc = Arc::new(Backend); + let repos: HashMap> = HashMap::default(); + let (msg_tx, msg_rx) = std::sync::mpsc::channel::(); + + let state = AppState { + git_runtime: gitcomet_core::process::GitRuntimeState { + preference: gitcomet_core::process::GitExecutablePreference::Custom(PathBuf::new()), + availability: gitcomet_core::process::GitExecutableAvailability::Unavailable { + detail: "Custom Git executable is not configured. Choose an executable or switch back to System PATH.".to_string(), + }, + }, + ..AppState::default() + }; + + schedule_effect_with_state_for_test( + &executor, + &executor, + &backend, + &repos, + state, + msg_tx, + Effect::FetchAll { + repo_id: RepoId(7), + prune: true, + auth: None, + }, + ); + + let msg = msg_rx + .recv_timeout(Duration::from_secs(1)) + .expect("expected synthetic unavailable-git message"); + match msg { + Msg::Internal(crate::msg::InternalMsg::RepoCommandFinished { + repo_id, + command, + result, + }) => { + assert_eq!(repo_id, RepoId(7)); + assert_eq!(command, RepoCommandKind::FetchAll); + let err = result.expect_err("expected unavailable-git failure"); + assert!( + err.to_string() + .contains("Custom Git executable is not configured"), + "unexpected error: {err}" + ); + } + other => panic!("unexpected message: {other:?}"), + } +} + #[test] fn clone_repo_effect_clones_local_repo_and_emits_finished_and_open_repo() { if !super::require_git_shell_for_store_tests() { diff --git a/crates/gitcomet-state/src/store/tests/external_and_history.rs b/crates/gitcomet-state/src/store/tests/external_and_history.rs index 3b3c307a..2d74fb15 100644 --- a/crates/gitcomet-state/src/store/tests/external_and_history.rs +++ b/crates/gitcomet-state/src/store/tests/external_and_history.rs @@ -44,9 +44,18 @@ fn external_worktree_change_refreshes_status_and_selected_diff() { &mut repos, &id_alloc, &mut state, - Msg::Internal(crate::msg::InternalMsg::StatusLoaded { + Msg::Internal(crate::msg::InternalMsg::WorktreeStatusLoaded { + repo_id: RepoId(1), + result: Ok(Vec::new()), + }), + ); + reduce( + &mut repos, + &id_alloc, + &mut state, + Msg::Internal(crate::msg::InternalMsg::StagedStatusLoaded { repo_id: RepoId(1), - result: Ok(gitcomet_core::domain::RepoStatus::default()), + result: Ok(Vec::new()), }), ); @@ -61,9 +70,7 @@ fn external_worktree_change_refreshes_status_and_selected_diff() { ); assert!( - effects - .iter() - .any(|e| matches!(e, Effect::LoadStatus { repo_id } if *repo_id == RepoId(1))), + has_worktree_status_effect(&effects, RepoId(1)), "expected status refresh" ); assert!( @@ -189,9 +196,18 @@ fn external_git_state_change_refreshes_history_and_selected_diff() { &mut repos, &id_alloc, &mut state, - Msg::Internal(crate::msg::InternalMsg::StatusLoaded { + Msg::Internal(crate::msg::InternalMsg::WorktreeStatusLoaded { repo_id: RepoId(1), - result: Ok(gitcomet_core::domain::RepoStatus::default()), + result: Ok(Vec::new()), + }), + ); + reduce( + &mut repos, + &id_alloc, + &mut state, + Msg::Internal(crate::msg::InternalMsg::StagedStatusLoaded { + repo_id: RepoId(1), + result: Ok(Vec::new()), }), ); reduce( @@ -244,9 +260,7 @@ fn external_git_state_change_refreshes_history_and_selected_diff() { "expected history refresh" ); assert!( - effects - .iter() - .any(|e| matches!(e, Effect::LoadStatus { repo_id } if *repo_id == RepoId(1))), + has_status_refresh_effects(&effects, RepoId(1)), "expected status refresh" ); assert!( @@ -340,11 +354,7 @@ fn external_git_state_refresh_is_coalesced_and_replayed_once() { .iter() .any(|e| matches!(e, Effect::LoadRebaseAndMergeState { .. })) ); - assert!( - effects1 - .iter() - .any(|e| matches!(e, Effect::LoadStatus { .. })) - ); + assert!(has_status_refresh_effects(&effects1, RepoId(1))); assert!(effects1.iter().any(|e| matches!(e, Effect::LoadLog { .. }))); // Second refresh request while the first one is in flight is coalesced into a single pending @@ -424,14 +434,28 @@ fn external_git_state_refresh_is_coalesced_and_replayed_once() { &mut repos, &id_alloc, &mut state, - Msg::Internal(crate::msg::InternalMsg::StatusLoaded { + Msg::Internal(crate::msg::InternalMsg::WorktreeStatusLoaded { + repo_id: RepoId(1), + result: Ok(Vec::new()), + }), + ); + assert!(matches!( + effects.as_slice(), + [Effect::LoadWorktreeStatus { repo_id: RepoId(1) }] + )); + + let effects = reduce( + &mut repos, + &id_alloc, + &mut state, + Msg::Internal(crate::msg::InternalMsg::StagedStatusLoaded { repo_id: RepoId(1), - result: Ok(gitcomet_core::domain::RepoStatus::default()), + result: Ok(Vec::new()), }), ); assert!(matches!( effects.as_slice(), - [Effect::LoadStatus { repo_id: RepoId(1) }] + [Effect::LoadStagedStatus { repo_id: RepoId(1) }] )); let effects = reduce( @@ -484,9 +508,7 @@ fn external_worktree_refresh_with_unchanged_status_settles_without_replay_loop() }, ); assert!( - effects - .iter() - .any(|e| matches!(e, Effect::LoadStatus { repo_id: rid } if *rid == repo_id)), + has_worktree_status_effect(&effects, repo_id), "expected first worktree event to request status refresh" ); @@ -508,15 +530,15 @@ fn external_worktree_refresh_with_unchanged_status_settles_without_replay_loop() &mut repos, &id_alloc, &mut state, - Msg::Internal(crate::msg::InternalMsg::StatusLoaded { + Msg::Internal(crate::msg::InternalMsg::WorktreeStatusLoaded { repo_id, - result: Ok(RepoStatus::default()), + result: Ok(Vec::new()), }), ); assert!( effects .iter() - .all(|e| !matches!(e, Effect::LoadStatus { repo_id: rid } if *rid == repo_id)), + .all(|e| !matches!(e, Effect::LoadWorktreeStatus { repo_id: rid } if *rid == repo_id)), "unchanged status payload should not replay another status load, got {effects:?}" ); assert!( @@ -534,15 +556,13 @@ fn external_worktree_refresh_with_unchanged_status_settles_without_replay_loop() }, ); assert!( - effects - .iter() - .any(|e| matches!(e, Effect::LoadStatus { repo_id: rid } if *rid == repo_id)), + has_worktree_status_effect(&effects, repo_id), "subsequent real worktree events should still trigger status refresh" ); } #[test] -fn external_worktree_refresh_is_fully_coalesced_while_status_is_in_flight() { +fn external_worktree_refresh_coalesces_status_while_status_is_in_flight() { let mut repos: HashMap> = HashMap::default(); let id_alloc = AtomicU64::new(1); let mut state = AppState::default(); @@ -577,9 +597,7 @@ fn external_worktree_refresh_is_fully_coalesced_while_status_is_in_flight() { }, ); assert!( - effects1 - .iter() - .any(|e| matches!(e, Effect::LoadStatus { repo_id: RepoId(1) })), + has_worktree_status_effect(&effects1, RepoId(1)), "expected first refresh to request status" ); assert!( @@ -603,8 +621,28 @@ fn external_worktree_refresh_is_fully_coalesced_while_status_is_in_flight() { }, ); assert!( - effects2.is_empty(), - "coalesced worktree refresh should not emit duplicate diff/status effects, got {effects2:?}" + !has_worktree_status_effect(&effects2, RepoId(1)), + "coalesced worktree refresh should not emit duplicate status effects, got {effects2:?}" + ); + assert!( + effects2.iter().any(|e| matches!( + e, + Effect::LoadDiff { + repo_id: RepoId(1), + .. + } + )), + "selected diff should still refresh on subsequent worktree changes" + ); + assert!( + effects2.iter().any(|e| matches!( + e, + Effect::LoadDiffFile { + repo_id: RepoId(1), + .. + } + )), + "selected diff file should still refresh on subsequent worktree changes" ); } @@ -632,17 +670,22 @@ fn reload_repo_sets_sections_loading_and_emits_refresh_effects() { assert!(repo_state.head_branch.is_loading()); assert!(repo_state.branches.is_loading()); assert!(repo_state.tags.is_loading()); - assert!(repo_state.remote_tags.is_loading()); + assert!(matches!(repo_state.remote_tags, Loadable::NotLoaded)); assert!(repo_state.remotes.is_loading()); assert!(repo_state.remote_branches.is_loading()); assert!(repo_state.status.is_loading()); + assert!(repo_state.worktree_status_is_loading()); + assert!(repo_state.staged_status_is_loading()); assert!(repo_state.log.is_loading()); assert!(!repo_state.history_state.log_loading_more); assert!(repo_state.merge_commit_message.is_loading()); + assert!(has_status_refresh_effects(&effects, RepoId(1))); assert!( - effects - .iter() - .any(|e| matches!(e, Effect::LoadStatus { repo_id: RepoId(1) })) + !effects.iter().any(|effect| matches!( + effect, + Effect::LoadRemoteTags { repo_id } if *repo_id == RepoId(1) + )), + "remote tags should lazy-load from tag UI, not repo reload" ); } diff --git a/crates/gitcomet-state/src/store/tests/repo_management.rs b/crates/gitcomet-state/src/store/tests/repo_management.rs index edee2b3d..ee4d6158 100644 --- a/crates/gitcomet-state/src/store/tests/repo_management.rs +++ b/crates/gitcomet-state/src/store/tests/repo_management.rs @@ -4,7 +4,6 @@ use crate::model::{RepoLoadsInFlight, SidebarDataRequest}; fn mark_repo_switch_secondary_metadata_ready(repo: &mut RepoState) { repo.branches = Loadable::Ready(Arc::new(Vec::new())); repo.tags = Loadable::Ready(Arc::new(Vec::new())); - repo.remote_tags = Loadable::Ready(Arc::new(Vec::new())); repo.remotes = Loadable::Ready(Arc::new(Vec::new())); repo.remote_branches = Loadable::Ready(Arc::new(Vec::new())); repo.stashes = Loadable::Ready(Arc::new(Vec::new())); @@ -17,7 +16,6 @@ fn has_full_refresh_only_effects(effects: &[Effect], repo_id: RepoId) -> bool { matches!( effect, Effect::LoadTags { repo_id: candidate } - | Effect::LoadRemoteTags { repo_id: candidate } | Effect::LoadRemotes { repo_id: candidate } | Effect::LoadRemoteBranches { repo_id: candidate } if *candidate == repo_id @@ -149,9 +147,7 @@ fn open_repo_focuses_existing_repo_instead_of_opening_duplicate() { ); assert!( - effects - .iter() - .any(|e| matches!(e, Effect::LoadStatus { repo_id } if *repo_id == RepoId(1))), + has_status_refresh_effects(&effects, RepoId(1)), "expected status refresh when focusing an already open repo" ); assert_eq!(state.repos.len(), 2); @@ -215,9 +211,7 @@ fn open_repo_allows_same_basename_in_different_folders() { Msg::OpenRepo(repo_a.clone()), ); assert!( - effects - .iter() - .any(|e| matches!(e, Effect::LoadStatus { repo_id } if *repo_id == RepoId(1))), + has_status_refresh_effects(&effects, RepoId(1)), "expected status refresh when re-focusing repo by path" ); assert_eq!(state.repos.len(), 2); @@ -259,9 +253,7 @@ fn open_repo_refreshes_when_repo_is_already_active() { assert_eq!(state.repos.len(), 1); assert_eq!(state.active_repo, Some(RepoId(1))); assert!( - effects - .iter() - .any(|e| matches!(e, Effect::LoadStatus { repo_id } if *repo_id == RepoId(1))), + has_status_refresh_effects(&effects, RepoId(1)), "expected status refresh when re-opening active repo" ); } @@ -1055,7 +1047,8 @@ fn set_active_repo_waits_for_repo_open_before_refreshing() { assert!( !effects.iter().any(|effect| matches!( effect, - Effect::LoadStatus { .. } + Effect::LoadWorktreeStatus { .. } + | Effect::LoadStagedStatus { .. } | Effect::LoadBranches { .. } | Effect::LoadWorktrees { .. } | Effect::LoadSelectedDiff { .. } @@ -1374,9 +1367,7 @@ fn set_active_repo_refreshes_repo_state_and_selected_diff() { assert_eq!(state.active_repo, Some(repo1)); - let has_status = effects - .iter() - .any(|e| matches!(e, Effect::LoadStatus { repo_id } if *repo_id == repo1)); + let has_status = has_status_refresh_effects(&effects, repo1); let has_log = effects.iter().any(|e| { matches!(e, Effect::LoadLog { repo_id, scope: _, limit: _, cursor: _ } if *repo_id == repo1) }); @@ -1648,11 +1639,7 @@ fn set_active_repo_hot_switch_skips_secondary_refresh_when_metadata_is_ready() { has_worktree_refresh_effect(&effects, repo1), "expected worktrees refresh on activation" ); - assert!( - effects - .iter() - .any(|effect| matches!(effect, Effect::LoadStatus { repo_id } if *repo_id == repo1)) - ); + assert!(has_status_refresh_effects(&effects, repo1)); assert!( effects .iter() @@ -1829,10 +1816,12 @@ fn repo_opened_ok_sets_loading_and_emits_refresh_effects() { assert!(repo_state.head_branch.is_loading()); assert!(repo_state.branches.is_loading()); assert!(repo_state.tags.is_loading()); - assert!(repo_state.remote_tags.is_loading()); + assert!(matches!(repo_state.remote_tags, Loadable::NotLoaded)); assert!(repo_state.remotes.is_loading()); assert!(repo_state.remote_branches.is_loading()); assert!(repo_state.status.is_loading()); + assert!(repo_state.worktree_status_is_loading()); + assert!(repo_state.staged_status_is_loading()); assert!(repo_state.log.is_loading()); assert!(matches!(repo_state.stashes, Loadable::NotLoaded)); assert!(matches!(repo_state.reflog, Loadable::NotLoaded)); @@ -1867,13 +1856,7 @@ fn repo_opened_ok_sets_loading_and_emits_refresh_effects() { ) } )); - assert!(has_effect_for_repo( - &effects, - RepoId(1), - |effect, repo_id| { - matches!(effect, Effect::LoadStatus { repo_id: candidate } if *candidate == repo_id) - } - )); + assert!(has_status_refresh_effects(&effects, RepoId(1))); assert!(has_effect_for_repo( &effects, RepoId(1), @@ -1895,18 +1878,13 @@ fn repo_opened_ok_sets_loading_and_emits_refresh_effects() { matches!(effect, Effect::LoadTags { repo_id: candidate } if *candidate == repo_id) } )); - assert!(has_effect_for_repo( - &effects, - RepoId(1), - |effect, repo_id| { - matches!( - effect, - Effect::LoadRemoteTags { - repo_id: candidate - } if *candidate == repo_id - ) - } - )); + assert!( + !effects.iter().any(|effect| matches!( + effect, + Effect::LoadRemoteTags { repo_id } if *repo_id == RepoId(1) + )), + "remote tags should lazy-load from tag UI, not repo open" + ); assert!(has_effect_for_repo( &effects, RepoId(1), @@ -1971,11 +1949,7 @@ fn repo_action_finished_clears_error_and_refreshes() { assert!(state.repos[0].last_error.is_none()); assert!(state.banner_error.is_none()); - assert!( - effects - .iter() - .any(|e| matches!(e, Effect::LoadStatus { repo_id: RepoId(1) })) - ); + assert!(has_status_refresh_effects(&effects, RepoId(1))); } #[test] diff --git a/crates/gitcomet-state/tests/session_integration.rs b/crates/gitcomet-state/tests/session_integration.rs index ecb6b5ae..ca7ee75b 100644 --- a/crates/gitcomet-state/tests/session_integration.rs +++ b/crates/gitcomet-state/tests/session_integration.rs @@ -302,9 +302,13 @@ fn persist_ui_settings_to_path_updates_optional_fields_and_requires_both_window_ diff_scroll_sync: Some("both".to_string()), change_tracking_height: Some(222), untracked_height: Some(111), + history_show_graph: Some(true), history_show_author: Some(false), history_show_date: Some(true), history_show_sha: Some(false), + history_show_tags: Some(false), + history_tag_fetch_mode: Some(gitcomet_state::model::GitLogTagFetchMode::Disabled), + git_executable_path: None, }, &session_file, ) @@ -329,9 +333,15 @@ fn persist_ui_settings_to_path_updates_optional_fields_and_requires_both_window_ assert_eq!(loaded.diff_scroll_sync.as_deref(), Some("both")); assert_eq!(loaded.change_tracking_height, Some(222)); assert_eq!(loaded.untracked_height, Some(111)); + assert_eq!(loaded.history_show_graph, Some(true)); assert_eq!(loaded.history_show_author, Some(false)); assert_eq!(loaded.history_show_date, Some(true)); assert_eq!(loaded.history_show_sha, Some(false)); + assert_eq!(loaded.history_show_tags, Some(false)); + assert_eq!( + loaded.history_tag_fetch_mode, + Some(gitcomet_state::model::GitLogTagFetchMode::Disabled) + ); session::persist_ui_settings_to_path( UiSettings { diff --git a/crates/gitcomet-ui-gpui/src/app.rs b/crates/gitcomet-ui-gpui/src/app.rs index 97114c20..af55b956 100644 --- a/crates/gitcomet-ui-gpui/src/app.rs +++ b/crates/gitcomet-ui-gpui/src/app.rs @@ -419,6 +419,9 @@ fn install_app_actions(cx: &mut App, backend: Arc) { cx.on_action(move |_: &OpenRepository, cx| { let backend = Arc::clone(&repo_backend); cx.defer(move |cx| { + if active_normal_gitcomet_window_blocks_non_repository_actions(cx) { + return; + } prompt_open_repository(cx, backend); }); }); diff --git a/crates/gitcomet-ui-gpui/src/kit/text_input.rs b/crates/gitcomet-ui-gpui/src/kit/text_input.rs index 02af0a55..add72a8d 100644 --- a/crates/gitcomet-ui-gpui/src/kit/text_input.rs +++ b/crates/gitcomet-ui-gpui/src/kit/text_input.rs @@ -3631,6 +3631,7 @@ impl Render for TextInput { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let style = self.style; let focus = self.focus_handle.clone(); + let entity_id = cx.entity().entity_id(); let chromeless = self.chromeless; let multiline = self.multiline; let pad_x = if chromeless { px(0.0) } else { px(8.0) }; @@ -3763,7 +3764,19 @@ impl Render for TextInput { input = input.focus(move |s| s.border_color(style.focus_border)); } - let mut outer = div().w_full().min_w(px(0.0)).flex().flex_col().child(input); + let render_id = ElementId::from(("text_input_root", entity_id)); + let render_id = + ElementId::from((render_id, if is_focused { "focused" } else { "blurred" })); + let mut outer = div() + // Focus changes toggle GPUI platform input handler registration during paint. + // Key the subtree by focus state so GPUI doesn't reuse a stale unfocused paint + // range that contains no input handlers when the field becomes focused. + .id(render_id) + .w_full() + .min_w(px(0.0)) + .flex() + .flex_col() + .child(input); if let Some(state) = self.context_menu { outer = outer.child( diff --git a/crates/gitcomet-ui-gpui/src/smoke_tests.rs b/crates/gitcomet-ui-gpui/src/smoke_tests.rs index 56a1fe95..5621eba1 100644 --- a/crates/gitcomet-ui-gpui/src/smoke_tests.rs +++ b/crates/gitcomet-ui-gpui/src/smoke_tests.rs @@ -351,6 +351,29 @@ fn text_input_constructs_without_panicking(cx: &mut gpui::TestAppContext) { }); } +#[gpui::test] +fn text_input_focus_after_initial_draw_accepts_typed_input(cx: &mut gpui::TestAppContext) { + let (view, cx) = cx.add_window_view(TextInputHostView::new); + + cx.update(|window, app| { + let _ = window.draw(app); + }); + + cx.update(|window, app| { + let focus = view.update(app, |this, cx| this.input.read(cx).focus_handle()); + window.focus(&focus, app); + let _ = window.draw(app); + }); + + cx.simulate_input("x"); + + let text = cx.update(|window, app| { + let _ = window.draw(app); + view.read(app).input.read(app).text().to_string() + }); + assert_eq!(text, "x"); +} + #[gpui::test] fn text_input_supports_basic_clipboard_and_word_shortcuts(cx: &mut gpui::TestAppContext) { let _clipboard_guard = lock_clipboard_test(); diff --git a/crates/gitcomet-ui-gpui/src/view/caches.rs b/crates/gitcomet-ui-gpui/src/view/caches.rs index c0a48e7d..51c371dd 100644 --- a/crates/gitcomet-ui-gpui/src/view/caches.rs +++ b/crates/gitcomet-ui-gpui/src/view/caches.rs @@ -685,7 +685,8 @@ pub(super) fn branch_sidebar_cache_store( #[derive(Clone, Debug)] pub(super) struct HistoryWorktreeSummaryCache { pub(super) repo_id: RepoId, - pub(super) status: Arc, + pub(super) worktree_status_rev: u64, + pub(super) staged_status_rev: u64, pub(super) show_row: bool, pub(super) counts: (usize, usize, usize), } diff --git a/crates/gitcomet-ui-gpui/src/view/diff_utils.rs b/crates/gitcomet-ui-gpui/src/view/diff_utils.rs index f4fa03a9..302395ad 100644 --- a/crates/gitcomet-ui-gpui/src/view/diff_utils.rs +++ b/crates/gitcomet-ui-gpui/src/view/diff_utils.rs @@ -133,6 +133,10 @@ const SVG_PREVIEW_MAX_RASTER_EDGE_PX: f32 = 4096.0; static SVG_PREVIEW_USVG_OPTIONS: std::sync::LazyLock> = std::sync::LazyLock::new(resvg::usvg::Options::default); +pub(in crate::view) fn fill_svg_viewport_white(pixmap: &mut resvg::tiny_skia::Pixmap) { + pixmap.fill(resvg::tiny_skia::Color::WHITE); +} + pub(in crate::view) fn rasterize_svg_png( svg_bytes: &[u8], target_width_px: f32, @@ -164,6 +168,7 @@ pub(in crate::view) fn rasterize_svg_png( let raster_height = raster_height.max(1.0) as u32; let mut pixmap = resvg::tiny_skia::Pixmap::new(raster_width, raster_height)?; + fill_svg_viewport_white(&mut pixmap); let transform = resvg::tiny_skia::Transform::from_scale( raster_width as f32 / svg_width, raster_height as f32 / svg_height, @@ -1099,6 +1104,26 @@ mod tests { assert_eq!(height, (SVG_PREVIEW_MIN_RASTER_WIDTH_PX / 2.0) as u32); } + #[test] + fn rasterize_svg_preview_png_fills_transparent_viewport_white() { + let svg = br##" + +"##; + + let png = rasterize_svg_preview_png(svg).expect("svg should rasterize"); + let decoded = image::load_from_memory_with_format(&png, image::ImageFormat::Png) + .expect("decode png") + .into_rgba8(); + + assert_eq!(decoded.get_pixel(0, 0).0, [255, 255, 255, 255]); + assert_eq!( + decoded + .get_pixel(decoded.width() / 2, decoded.height() / 2) + .0, + [0, 170, 255, 255] + ); + } + #[test] fn context_menu_selection_range_from_diff_text_requires_click_in_selection() { let selection = Some(( diff --git a/crates/gitcomet-ui-gpui/src/view/mod.rs b/crates/gitcomet-ui-gpui/src/view/mod.rs index ae949fe4..9f9d06d3 100644 --- a/crates/gitcomet-ui-gpui/src/view/mod.rs +++ b/crates/gitcomet-ui-gpui/src/view/mod.rs @@ -2,11 +2,14 @@ use crate::theme::AppTheme; use gitcomet_core::diff::AnnotatedDiffLine; #[cfg(test)] use gitcomet_core::diff::annotate_unified; +#[cfg(test)] +use gitcomet_core::domain::RepoStatus; use gitcomet_core::domain::{ - Branch, Commit, CommitId, DiffArea, DiffTarget, FileStatus, FileStatusKind, RepoStatus, Tag, + Branch, Commit, CommitId, DiffArea, DiffTarget, FileStatus, FileStatusKind, Tag, UpstreamDivergence, }; use gitcomet_core::file_diff::FileDiffRow; +use gitcomet_core::process::refresh_git_runtime; use gitcomet_core::services::{PullMode, RemoteUrlKind, ResetMode}; use gitcomet_state::model::{ AppNotificationKind, AppState, AuthPromptKind, CloneOpState, CloneOpStatus, DiagnosticKind, @@ -17,12 +20,12 @@ use gitcomet_state::session; use gitcomet_state::store::AppStore; use gpui::prelude::*; use gpui::{ - Animation, AnimationExt, AnyElement, App, Bounds, ClickEvent, Corner, CursorStyle, Decorations, - Element, ElementId, Entity, FocusHandle, FontWeight, GlobalElementId, InspectorElementId, - IsZero, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, - Render, ResizeEdge, ScrollHandle, ShapedLine, SharedString, Size, Style, TextRun, Tiling, - UniformListScrollHandle, WeakEntity, Window, WindowControlArea, anchored, div, fill, point, px, - relative, size, uniform_list, + Animation, AnimationExt, AnyElement, AnyView, App, Bounds, ClickEvent, Corner, CursorStyle, + Decorations, Element, ElementId, Entity, FocusHandle, FontWeight, GlobalElementId, + InspectorElementId, IsZero, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, + MouseUpEvent, Pixels, Point, Render, ResizeEdge, ScrollHandle, ShapedLine, SharedString, Size, + Style, StyleRefinement, TextRun, Tiling, UniformListScrollHandle, WeakEntity, Window, + WindowControlArea, anchored, div, fill, point, px, relative, size, uniform_list, }; use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; #[cfg(test)] @@ -83,7 +86,8 @@ use caches::{ HistoryWorktreeSummaryCache, }; use chrome::{ - CLIENT_SIDE_DECORATION_INSET, TitleBarView, cursor_style_for_resize_edge, resize_edge, + CLIENT_SIDE_DECORATION_INSET, TITLE_BAR_HEIGHT, TitleBarView, cursor_style_for_resize_edge, + resize_edge, }; use conflict_resolver::{ConflictPickSide, ConflictResolverViewMode}; #[cfg(test)] @@ -113,7 +117,7 @@ pub use mod_helpers::{ FocusedMergetoolLabels, FocusedMergetoolViewConfig, GitCometView, GitCometViewConfig, GitCometViewMode, InitialRepositoryLaunchMode, StartupCrashReport, }; -use panels::{ActionBarView, PopoverHost, RepoTabsBarView}; +use panels::{ACTION_BAR_HEIGHT, ActionBarView, PopoverHost, RepoTabsBarView}; use panes::{DetailsPaneInit, DetailsPaneView, HistoryView, MainPaneView, SidebarPaneView}; pub(crate) use settings_window::open_settings_window; use toast_host::ToastHost; @@ -160,6 +164,36 @@ const TOAST_FADE_IN_MS: u64 = 180; const TOAST_FADE_OUT_MS: u64 = 220; const TOAST_SLIDE_PX: f32 = 12.0; +// Only use these wrappers for views that remain mounted while their parent is mounted. +// Parent-controlled mount/unmount boundaries, like collapsible panes, must rebuild their child. +fn stable_cached_view(view: Entity, style: StyleRefinement) -> AnyView { + let view = AnyView::from(view); + // GPUI's cached mount path skips some test-only debug bounds and paint tracking. + if cfg!(test) { view } else { view.cached(style) } +} + +fn stable_cached_fill_view(view: Entity) -> AnyView { + stable_cached_view(view, StyleRefinement::default().size_full()) +} + +fn stable_cached_fixed_height_view(view: Entity, height: Pixels) -> AnyView { + stable_cached_view( + view, + StyleRefinement::default().w_full().h(height).flex_none(), + ) +} + +fn stable_cached_overlay_view(view: Entity) -> AnyView { + stable_cached_view( + view, + StyleRefinement::default() + .absolute() + .top_0() + .left_0() + .size_full(), + ) +} + pub(in crate::view) fn pane_resize_handles_width( sidebar_collapsed: bool, details_collapsed: bool, @@ -453,35 +487,62 @@ impl GitCometView { let restored_change_tracking_height = ui_session.change_tracking_height; let restored_untracked_height = ui_session.untracked_height; + let history_show_graph = ui_session.history_show_graph.unwrap_or(true); let history_show_author = ui_session.history_show_author.unwrap_or(true); let history_show_date = ui_session.history_show_date.unwrap_or(true); let history_show_sha = ui_session.history_show_sha.unwrap_or(false); + let history_show_tags = ui_session.history_show_tags.unwrap_or(true); + let history_tag_fetch_mode = ui_session.history_tag_fetch_mode.unwrap_or_default(); + store.dispatch(Msg::SetGitLogSettings { + show_history_tags: history_show_tags, + tag_fetch_mode: history_tag_fetch_mode, + }); + let saved_open_repos = ui_session.open_repos.clone(); + let saved_active_repo = ui_session.active_repo.clone(); let mut startup_repo_bootstrap_pending = false; + let mut deferred_repo_bootstrap = None; // Only auto-restore/open on startup if the store hasn't already been preloaded. // This avoids re-opening repos (and changing RepoIds) when the UI is attached to an // already-initialized store (notably in `gpui::test` setup). - let store_preloaded = !store.snapshot().repos.is_empty(); + let initial_store_state = store.snapshot(); + let store_preloaded = !initial_store_state.repos.is_empty(); + let git_runtime_available = initial_store_state.git_runtime.is_available(); let should_auto_restore = !crate::startup_probe::disable_auto_restore() && view_mode != GitCometViewMode::FocusedMergetool && crate::ui_runtime::current().auto_restores_session() && !store_preloaded; if should_auto_restore { - if !ui_session.open_repos.is_empty() { - store.dispatch(Msg::RestoreSession { - open_repos: ui_session.open_repos, - active_repo: ui_session.active_repo, - }); - startup_repo_bootstrap_pending = true; + if !saved_open_repos.is_empty() { + if git_runtime_available { + store.dispatch(Msg::RestoreSession { + open_repos: saved_open_repos, + active_repo: saved_active_repo, + }); + startup_repo_bootstrap_pending = true; + } else { + deferred_repo_bootstrap = Some(DeferredRepoBootstrap::RestoreSession { + open_repos: saved_open_repos, + active_repo: saved_active_repo, + }); + } } } else if store_preloaded { if let Some(path) = initial_path.as_ref() { - store.dispatch(Msg::OpenRepo(path.clone())); + if git_runtime_available { + store.dispatch(Msg::OpenRepo(path.clone())); + } else { + deferred_repo_bootstrap = Some(DeferredRepoBootstrap::OpenRepo(path.clone())); + } } } else if let Some(path) = initial_path.as_ref() { - store.dispatch(Msg::OpenRepo(path.clone())); - startup_repo_bootstrap_pending = true; + if git_runtime_available { + store.dispatch(Msg::OpenRepo(path.clone())); + startup_repo_bootstrap_pending = true; + } else { + deferred_repo_bootstrap = Some(DeferredRepoBootstrap::OpenRepo(path.clone())); + } } let initial_state = store.snapshot(); @@ -556,9 +617,15 @@ impl GitCometView { timezone, show_timezone, diff_scroll_sync, + history_show_graph, history_show_author, history_show_date, history_show_sha, + history_show_tags, + matches!( + history_tag_fetch_mode, + gitcomet_state::model::GitLogTagFetchMode::OnRepositoryActivation + ), view_mode, focused_mergetool_labels, focused_mergetool_exit_code.clone(), @@ -607,6 +674,14 @@ impl GitCometView { if !window.is_window_active() { return; } + let runtime = refresh_git_runtime(); + if runtime != this.state.git_runtime { + this.store + .dispatch(Msg::SetGitRuntimeState(runtime.clone())); + } + if !runtime.is_available() { + return; + } if let Some(repo) = this.active_repo() && matches!(repo.open, Loadable::Ready(_)) { @@ -725,6 +800,7 @@ impl GitCometView { toast_host, popover_host, focused_mergetool_bootstrap, + deferred_repo_bootstrap, startup_repo_bootstrap_pending, splash_backdrop_image: splash::load_splash_backdrop_image(), last_window_size: size(px(0.0), px(0.0)), @@ -884,6 +960,52 @@ impl GitCometView { self.schedule_ui_settings_persist(cx); } + pub(in crate::view) fn set_history_column_preferences( + &mut self, + show_graph: bool, + show_author: bool, + show_date: bool, + show_sha: bool, + cx: &mut gpui::Context, + ) { + self.main_pane.update(cx, |pane, cx| { + pane.set_history_column_preferences(show_graph, show_author, show_date, show_sha, cx); + }); + self.schedule_ui_settings_persist(cx); + } + + pub(in crate::view) fn reset_history_column_widths(&mut self, cx: &mut gpui::Context) { + self.main_pane + .update(cx, |pane, cx| pane.reset_history_column_widths(cx)); + self.schedule_ui_settings_persist(cx); + } + + pub(in crate::view) fn set_history_tag_preferences( + &mut self, + show_tags: bool, + tag_fetch_mode: gitcomet_state::model::GitLogTagFetchMode, + cx: &mut gpui::Context, + ) { + let auto_fetch_tags_on_repo_activation = matches!( + tag_fetch_mode, + gitcomet_state::model::GitLogTagFetchMode::OnRepositoryActivation + ); + self.main_pane.update(cx, |pane, cx| { + pane.set_history_tag_preferences(show_tags, auto_fetch_tags_on_repo_activation, cx); + }); + self.store.dispatch(Msg::SetGitLogSettings { + show_history_tags: show_tags, + tag_fetch_mode, + }); + if show_tags + && let Some(repo) = self.main_pane.read(cx).active_repo() + && matches!(repo.tags, Loadable::NotLoaded | Loadable::Error(_)) + { + self.store.dispatch(Msg::LoadTags { repo_id: repo.id }); + } + self.schedule_ui_settings_persist(cx); + } + fn refresh_main_pane_after_panel_animation(&mut self, cx: &mut gpui::Context) { let main_pane = self.main_pane.clone(); cx.defer(move |cx| { @@ -1223,6 +1345,10 @@ impl GitCometView { } fn drive_focused_mergetool_bootstrap(&mut self) { + if !self.state.git_runtime.is_available() { + return; + } + let Some(bootstrap) = self.focused_mergetool_bootstrap.as_ref() else { return; }; @@ -1399,6 +1525,65 @@ impl GitCometView { fn open_external_url(&mut self, url: &str) -> Result<(), std::io::Error> { platform_open::open_url(url) } + + #[cfg(test)] + pub(crate) fn is_popover_open(&self, app: &App) -> bool { + self.popover_host.read(app).is_open() + } + + #[cfg(test)] + pub(crate) fn tooltip_text_for_test(&self, app: &App) -> Option { + self.tooltip_host.read(app).tooltip_text_for_test() + } + + #[cfg(test)] + pub(crate) fn open_repo_panel_visible_for_test(&self) -> bool { + self.open_repo_panel + } + + #[cfg(test)] + pub(crate) fn show_timezone_for_test(&self) -> bool { + self.show_timezone + } + + #[cfg(test)] + pub(in crate::view) fn change_tracking_view_for_test(&self) -> ChangeTrackingView { + self.change_tracking_view + } + + fn resume_after_git_runtime_recovery(&mut self) { + if let Some(bootstrap) = self.deferred_repo_bootstrap.take() { + match bootstrap { + DeferredRepoBootstrap::RestoreSession { + open_repos, + active_repo, + } => { + self.startup_repo_bootstrap_pending = true; + self.store.dispatch(Msg::RestoreSession { + open_repos, + active_repo, + }); + } + DeferredRepoBootstrap::OpenRepo(path) => { + self.startup_repo_bootstrap_pending = true; + self.store.dispatch(Msg::OpenRepo(path)); + } + } + return; + } + + if !self.state.repos.is_empty() { + let repo_ids: Vec<_> = self.state.repos.iter().map(|repo| repo.id).collect(); + for repo_id in repo_ids { + self.store.dispatch(Msg::ReloadRepo { repo_id }); + } + } + } + + #[cfg(test)] + pub(in crate::view) fn diff_scroll_sync_for_test(&self) -> DiffScrollSync { + self.diff_scroll_sync + } } impl Render for GitCometView { @@ -1489,7 +1674,10 @@ impl Render for GitCometView { .text_color(theme.colors.text); if show_custom_window_chrome { - body = body.child(self.title_bar.clone()); + body = body.child(stable_cached_fixed_height_view( + self.title_bar.clone(), + TITLE_BAR_HEIGHT, + )); } body = body.child(center_content); @@ -1847,11 +2035,11 @@ impl Render for GitCometView { root = root.child(window_frame(theme, decorations, body.into_any_element())); - root = root.child(self.toast_host.clone()); + root = root.child(stable_cached_overlay_view(self.toast_host.clone())); - root = root.child(self.popover_host.clone()); + root = root.child(stable_cached_overlay_view(self.popover_host.clone())); - root = root.child(self.tooltip_host.clone()); + root = root.child(stable_cached_overlay_view(self.tooltip_host.clone())); if crate::startup_probe::is_enabled() { root = root.on_children_prepainted(|_children_bounds, window, _cx| { diff --git a/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs b/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs index 7334433b..789c6dc6 100644 --- a/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs +++ b/crates/gitcomet-ui-gpui/src/view/mod_helpers.rs @@ -580,6 +580,102 @@ impl StatusSection { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum StatusSectionFilter { + All, + UntrackedOnly, + ExcludeUntracked, +} + +#[derive(Clone, Copy)] +pub(super) struct StatusSectionEntries<'a> { + entries: &'a [FileStatus], + filter: StatusSectionFilter, +} + +impl<'a> StatusSectionEntries<'a> { + pub(super) fn from_repo(repo: &'a RepoState, section: StatusSection) -> Option { + let (entries, filter) = match section { + StatusSection::CombinedUnstaged => { + (repo.worktree_status_entries()?, StatusSectionFilter::All) + } + StatusSection::Untracked => ( + repo.worktree_status_entries()?, + StatusSectionFilter::UntrackedOnly, + ), + StatusSection::Unstaged => ( + repo.worktree_status_entries()?, + StatusSectionFilter::ExcludeUntracked, + ), + StatusSection::Staged => (repo.staged_status_entries()?, StatusSectionFilter::All), + }; + Some(Self { entries, filter }) + } + + pub(super) fn iter(self) -> StatusSectionIter<'a> { + StatusSectionIter { + inner: self.entries.iter(), + filter: self.filter, + } + } + + pub(super) fn len(self) -> usize { + self.iter().count() + } + + pub(super) fn get(self, index: usize) -> Option<&'a FileStatus> { + self.iter().nth(index) + } + + pub(super) fn path_vec(self) -> Vec { + self.iter().map(|entry| entry.path.clone()).collect() + } + + pub(super) fn contains_path(self, path: &std::path::Path) -> bool { + self.iter().any(|entry| entry.path == path) + } +} + +pub(super) struct StatusSectionIter<'a> { + inner: std::slice::Iter<'a, FileStatus>, + filter: StatusSectionFilter, +} + +impl<'a> Iterator for StatusSectionIter<'a> { + type Item = &'a FileStatus; + + fn next(&mut self) -> Option { + self.inner + .find(|entry| status_section_filter_matches(self.filter, entry)) + } +} + +fn status_section_filter_matches(filter: StatusSectionFilter, entry: &FileStatus) -> bool { + match filter { + StatusSectionFilter::All => true, + StatusSectionFilter::UntrackedOnly => entry.kind == FileStatusKind::Untracked, + StatusSectionFilter::ExcludeUntracked => entry.kind != FileStatusKind::Untracked, + } +} + +pub(super) fn status_section_rev(repo: &RepoState, section: StatusSection) -> u64 { + match section { + StatusSection::Staged => repo.staged_status_cache_rev(), + StatusSection::CombinedUnstaged | StatusSection::Untracked | StatusSection::Unstaged => { + repo.worktree_status_cache_rev() + } + } +} + +pub(super) fn status_section_is_loading(repo: &RepoState, section: StatusSection) -> bool { + match section { + StatusSection::Staged => repo.staged_status_is_loading(), + StatusSection::CombinedUnstaged | StatusSection::Untracked | StatusSection::Unstaged => { + repo.worktree_status_is_loading() + } + } +} + #[derive(Clone, Debug, Default)] pub(super) struct StatusMultiSelection { pub(super) untracked: Vec, @@ -634,9 +730,10 @@ impl StatusMultiSelection { } } +#[cfg(test)] pub(super) fn reconcile_status_multi_selection( selection: &mut StatusMultiSelection, - status: &RepoStatus, + status: &gitcomet_core::domain::RepoStatus, ) { let mut untracked_paths: HashSet<&std::path::Path> = HashSet::with_capacity_and_hasher(status.unstaged.len(), Default::default()); @@ -693,6 +790,69 @@ pub(super) fn reconcile_status_multi_selection( } } +pub(super) fn reconcile_status_multi_selection_with_repo( + selection: &mut StatusMultiSelection, + repo: &RepoState, +) { + if let Some(worktree) = repo.worktree_status_entries() { + let mut untracked_paths: HashSet<&std::path::Path> = + HashSet::with_capacity_and_hasher(worktree.len(), Default::default()); + let mut unstaged_paths: HashSet<&std::path::Path> = + HashSet::with_capacity_and_hasher(worktree.len(), Default::default()); + for entry in worktree { + unstaged_paths.insert(entry.path.as_path()); + if entry.kind == FileStatusKind::Untracked { + untracked_paths.insert(entry.path.as_path()); + } + } + + selection + .untracked + .retain(|p| untracked_paths.contains(&p.as_path())); + if selection + .untracked_anchor + .as_ref() + .is_some_and(|a| !untracked_paths.contains(&a.as_path())) + { + selection.untracked_anchor = None; + } + + selection + .unstaged + .retain(|p| unstaged_paths.contains(&p.as_path())); + if selection + .unstaged_anchor + .as_ref() + .is_some_and(|a| !unstaged_paths.contains(&a.as_path())) + { + selection.unstaged_anchor = None; + selection.unstaged_anchor_index = None; + selection.unstaged_anchor_status_rev = None; + } + } + + if let Some(staged) = repo.staged_status_entries() { + let mut staged_paths: HashSet<&std::path::Path> = + HashSet::with_capacity_and_hasher(staged.len(), Default::default()); + for entry in staged { + staged_paths.insert(entry.path.as_path()); + } + + selection + .staged + .retain(|p| staged_paths.contains(&p.as_path())); + if selection + .staged_anchor + .as_ref() + .is_some_and(|a| !staged_paths.contains(&a.as_path())) + { + selection.staged_anchor = None; + selection.staged_anchor_index = None; + selection.staged_anchor_status_rev = None; + } + } +} + #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub(super) enum ThreeWayColumn { Base, @@ -2166,7 +2326,6 @@ pub(super) enum PopoverKind { HistoryBranchFilter { repo_id: RepoId, }, - HistoryColumnSettings, ChangeTrackingSettings, } @@ -2366,6 +2525,15 @@ pub(super) enum FocusedMergetoolBootstrapAction { Complete, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) enum DeferredRepoBootstrap { + RestoreSession { + open_repos: Vec, + active_repo: Option, + }, + OpenRepo(std::path::PathBuf), +} + pub(super) fn normalize_bootstrap_repo_path(path: std::path::PathBuf) -> std::path::PathBuf { let path = if path.is_relative() { std::env::current_dir() @@ -2659,6 +2827,7 @@ pub struct GitCometView { pub(super) toast_host: Entity, pub(super) popover_host: Entity, pub(super) focused_mergetool_bootstrap: Option, + pub(super) deferred_repo_bootstrap: Option, pub(super) startup_repo_bootstrap_pending: bool, pub(super) splash_backdrop_image: Arc, diff --git a/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs b/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs index d63bb1de..642bf812 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/action_bar.rs @@ -4,6 +4,8 @@ use rustc_hash::FxHasher; use std::hash::{Hash, Hasher}; use std::sync::Arc; +pub(in super::super) const ACTION_BAR_HEIGHT: Pixels = px(components::CONTROL_HEIGHT_PX + 8.0); + fn head_branch_has_tracking_upstream( head_branch: &Loadable, branches: &Loadable>>, @@ -67,7 +69,7 @@ impl ActionBarView { repo.upstream_divergence_rev.hash(&mut hasher); repo.merge_message_rev.hash(&mut hasher); repo.ops_rev.hash(&mut hasher); - repo.status_rev.hash(&mut hasher); + repo.status_cache_rev().hash(&mut hasher); repo.loads_in_flight.any_in_flight().hash(&mut hasher); } @@ -263,9 +265,12 @@ impl Render for ActionBarView { let can_stash = self .active_repo() - .and_then(|r| match &r.status { - Loadable::Ready(s) => Some(!s.staged.is_empty() || !s.unstaged.is_empty()), - _ => None, + .map(|repo| { + repo.worktree_status_entries() + .is_some_and(|entries| !entries.is_empty()) + || repo + .staged_status_entries() + .is_some_and(|entries| !entries.is_empty()) }) .unwrap_or(false); @@ -626,6 +631,9 @@ impl Render for ActionBarView { })); div() + .w_full() + .h(ACTION_BAR_HEIGHT) + .flex_none() .flex() .items_center() .justify_between() diff --git a/crates/gitcomet-ui-gpui/src/view/panels/layout.rs b/crates/gitcomet-ui-gpui/src/view/panels/layout.rs index a64fafaf..22342f72 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/layout.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/layout.rs @@ -121,7 +121,7 @@ fn explicit_status_section_action_paths( } fn active_status_section_action_path( - status: &RepoStatus, + repo: &RepoState, diff_target: Option<&DiffTarget>, section: StatusSection, ) -> Option { @@ -132,24 +132,13 @@ fn active_status_section_action_path( return None; } - let matches_section = match section { - StatusSection::CombinedUnstaged => status.unstaged.iter().any(|entry| entry.path == *path), - StatusSection::Untracked => status - .unstaged - .iter() - .any(|entry| entry.path == *path && entry.kind == FileStatusKind::Untracked), - StatusSection::Unstaged => status - .unstaged - .iter() - .any(|entry| entry.path == *path && entry.kind != FileStatusKind::Untracked), - StatusSection::Staged => status.staged.iter().any(|entry| entry.path == *path), - }; - - matches_section.then(|| path.clone()) + StatusSectionEntries::from_repo(repo, section) + .is_some_and(|entries| entries.contains_path(path.as_path())) + .then(|| path.clone()) } fn status_section_action_selection( - status: &RepoStatus, + repo: &RepoState, diff_target: Option<&DiffTarget>, selection: Option<&StatusMultiSelection>, section: StatusSection, @@ -164,7 +153,7 @@ fn status_section_action_selection( } } - active_status_section_action_path(status, diff_target, section) + active_status_section_action_path(repo, diff_target, section) .map(|path| StatusSectionActionSelection { paths: vec![path], from_explicit_selection: false, @@ -181,12 +170,9 @@ impl DetailsPaneView { let Some(repo) = self.active_repo().filter(|repo| repo.id == repo_id) else { return StatusSectionActionSelection::default(); }; - let Loadable::Ready(status) = &repo.status else { - return StatusSectionActionSelection::default(); - }; status_section_action_selection( - status, + repo, repo.diff_state.diff_target.as_ref(), self.status_multi_selection.get(&repo_id), section, @@ -406,10 +392,9 @@ impl DetailsPaneView { if repo.commit_in_flight > 0 { return false; } - let staged_count = match &repo.status { - Loadable::Ready(status) => status.staged.len(), - _ => 0, - }; + let staged_count = repo + .staged_status_entries() + .map_or(0, |entries| entries.len()); let is_merge_active = merge_active(Some(repo)); commit_allowed(is_merge_active, staged_count) && !message.trim().is_empty() } @@ -863,31 +848,43 @@ impl DetailsPaneView { .active_repo() .map(|r| r.local_actions_in_flight > 0) .unwrap_or(false); - let (staged_count, unstaged_count) = self + let (staged_count, unstaged_count, untracked_count, split_unstaged_count) = self .active_repo() - .and_then(|r| match &r.status { - Loadable::Ready(s) => Some((s.staged.len(), s.unstaged.len())), - _ => None, + .map(|repo| { + ( + StatusSectionEntries::from_repo(repo, StatusSection::Staged) + .map_or(0, StatusSectionEntries::len), + StatusSectionEntries::from_repo(repo, StatusSection::CombinedUnstaged) + .map_or(0, StatusSectionEntries::len), + StatusSectionEntries::from_repo(repo, StatusSection::Untracked) + .map_or(0, StatusSectionEntries::len), + StatusSectionEntries::from_repo(repo, StatusSection::Unstaged) + .map_or(0, StatusSectionEntries::len), + ) }) - .unwrap_or((0, 0)); - let (untracked_count, split_unstaged_count, untracked_paths, split_unstaged_paths) = self + .unwrap_or((0, 0, 0, 0)); + let (untracked_paths, split_unstaged_paths) = self .active_repo() - .and_then(|r| match &r.status { - Loadable::Ready(s) => { - let mut untracked = Vec::new(); - let mut tracked = Vec::new(); - for entry in &s.unstaged { - if entry.kind == FileStatusKind::Untracked { - untracked.push(entry.path.clone()); - } else { - tracked.push(entry.path.clone()); - } - } - Some((untracked.len(), tracked.len(), untracked, tracked)) - } - _ => None, + .map(|repo| { + ( + StatusSectionEntries::from_repo(repo, StatusSection::Untracked) + .map_or_else(Vec::new, StatusSectionEntries::path_vec), + StatusSectionEntries::from_repo(repo, StatusSection::Unstaged) + .map_or_else(Vec::new, StatusSectionEntries::path_vec), + ) }) - .unwrap_or_else(|| (0, 0, Vec::new(), Vec::new())); + .unwrap_or_else(|| (Vec::new(), Vec::new())); + let (unstaged_loading, untracked_loading, split_unstaged_loading, staged_loading) = self + .active_repo() + .map(|repo| { + ( + status_section_is_loading(repo, StatusSection::CombinedUnstaged), + status_section_is_loading(repo, StatusSection::Untracked), + status_section_is_loading(repo, StatusSection::Unstaged), + status_section_is_loading(repo, StatusSection::Staged), + ) + }) + .unwrap_or((false, false, false, false)); let repo_id = self.active_repo_id(); let selected_combined_unstaged = repo_id @@ -1299,25 +1296,33 @@ impl DetailsPaneView { actions.child(unstage_all).into_any_element() }; - let unstaged_body = if unstaged_count == 0 { + let unstaged_body = if unstaged_loading { + components::empty_state(theme, "Unstaged", "Loading").into_any_element() + } else if unstaged_count == 0 { components::empty_state(theme, "Unstaged", "Clean.").into_any_element() } else { self.status_list(cx, StatusSection::CombinedUnstaged, unstaged_count) }; - let untracked_body = if untracked_count == 0 { + let untracked_body = if untracked_loading { + components::empty_state(theme, "Untracked", "Loading").into_any_element() + } else if untracked_count == 0 { components::empty_state(theme, "Untracked", "No untracked files.").into_any_element() } else { self.status_list(cx, StatusSection::Untracked, untracked_count) }; - let split_unstaged_body = if split_unstaged_count == 0 { + let split_unstaged_body = if split_unstaged_loading { + components::empty_state(theme, "Unstaged", "Loading").into_any_element() + } else if split_unstaged_count == 0 { components::empty_state(theme, "Unstaged", "Clean.").into_any_element() } else { self.status_list(cx, StatusSection::Unstaged, split_unstaged_count) }; - let staged_list = if staged_count == 0 { + let staged_list = if staged_loading { + components::empty_state(theme, "Staged", "Loading").into_any_element() + } else if staged_count == 0 { components::empty_state(theme, "Staged", "No staged changes.").into_any_element() } else { self.status_list(cx, StatusSection::Staged, staged_count) @@ -1951,6 +1956,7 @@ mod tests { use gitcomet_core::domain::RepoSpec; use gitcomet_state::model::{Loadable, RepoId, RepoState}; use std::path::PathBuf; + use std::sync::Arc; fn test_repo() -> RepoState { RepoState::new_opening( @@ -1969,6 +1975,17 @@ mod tests { } } + fn repo_with_status(status: RepoStatus) -> RepoState { + let mut repo = test_repo(); + repo.worktree_status = Loadable::Ready(Arc::new(status.unstaged.clone())); + repo.worktree_status_rev = 1; + repo.staged_status = Loadable::Ready(Arc::new(status.staged.clone())); + repo.staged_status_rev = 1; + repo.status = Loadable::Ready(status.into()); + repo.status_rev = 1; + repo + } + #[test] fn commit_allowed_when_staged_changes_exist() { assert!(commit_allowed(false, 1)); @@ -2047,17 +2064,17 @@ mod tests { #[test] fn status_section_action_selection_falls_back_to_active_combined_unstaged_row() { - let status = RepoStatus { + let repo = repo_with_status(RepoStatus { unstaged: vec![file_status("src/lib.rs", FileStatusKind::Modified)], staged: Vec::new(), - }; + }); let diff_target = DiffTarget::WorkingTree { path: PathBuf::from("src/lib.rs"), area: DiffArea::Unstaged, }; let selection = status_section_action_selection( - &status, + &repo, Some(&diff_target), None, StatusSection::CombinedUnstaged, @@ -2075,26 +2092,26 @@ mod tests { #[test] fn status_section_action_selection_limits_active_row_to_matching_split_section() { - let status = RepoStatus { + let repo = repo_with_status(RepoStatus { unstaged: vec![ file_status("new.txt", FileStatusKind::Untracked), file_status("src/lib.rs", FileStatusKind::Modified), ], staged: Vec::new(), - }; + }); let diff_target = DiffTarget::WorkingTree { path: PathBuf::from("new.txt"), area: DiffArea::Unstaged, }; let untracked = status_section_action_selection( - &status, + &repo, Some(&diff_target), None, StatusSection::Untracked, ); let unstaged = status_section_action_selection( - &status, + &repo, Some(&diff_target), None, StatusSection::Unstaged, @@ -2114,7 +2131,7 @@ mod tests { fn status_section_action_selection_prefers_explicit_selection_over_active_row() { let selected_a = PathBuf::from("src/lib.rs"); let selected_b = PathBuf::from("src/main.rs"); - let status = RepoStatus { + let repo = repo_with_status(RepoStatus { unstaged: vec![ file_status( selected_a.to_string_lossy().as_ref(), @@ -2126,7 +2143,7 @@ mod tests { ), ], staged: Vec::new(), - }; + }); let diff_target = DiffTarget::WorkingTree { path: PathBuf::from("src/other.rs"), area: DiffArea::Unstaged, @@ -2137,7 +2154,7 @@ mod tests { }; let action_selection = status_section_action_selection( - &status, + &repo, Some(&diff_target), Some(&selection), StatusSection::CombinedUnstaged, diff --git a/crates/gitcomet-ui-gpui/src/view/panels/main/diff_view.rs b/crates/gitcomet-ui-gpui/src/view/panels/main/diff_view.rs index f0c66566..4a047e67 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/main/diff_view.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/main/diff_view.rs @@ -77,16 +77,10 @@ impl MainPaneView { if *area != DiffArea::Unstaged { return None; } - match &repo.status { - Loadable::Ready(status) => { - let conflict = status - .unstaged - .iter() - .find(|e| e.path == *path && e.kind == FileStatusKind::Conflicted)?; - Some((path.clone(), conflict.conflict)) - } - _ => None, - } + let conflict = repo + .status_entry_for_path(DiffArea::Unstaged, path.as_path()) + .filter(|entry| entry.kind == FileStatusKind::Conflicted)?; + Some((path.clone(), conflict.conflict)) }); let (conflict_target_path, conflict_kind) = conflict_target .map(|(path, kind)| (Some(path), kind)) @@ -2937,18 +2931,16 @@ impl MainPaneView { let path = path.clone(); let area = *area; let change_tracking_view = this.active_change_tracking_view(cx); - let next_path_in_section = match &repo.status { - Loadable::Ready(status) => status_nav::status_navigation_context( - status, - &diff_target, - change_tracking_view, - ) - .and_then(|navigation| navigation.next_or_prev_path()), - _ => None, - }; + let next_path_in_section = status_nav::status_navigation_context_for_repo( + repo, + &diff_target, + change_tracking_view, + ) + .and_then(|navigation| navigation.next_or_prev_path()); + let status_ready = repo.status_entries_for_area(area).is_some(); - match (&repo.status, area) { - (Loadable::Ready(_status), DiffArea::Unstaged) => { + match (status_ready, area) { + (true, DiffArea::Unstaged) => { this.store.dispatch(Msg::StagePath { repo_id, path: path.clone(), @@ -2965,7 +2957,7 @@ impl MainPaneView { this.clear_diff_selection_or_exit(repo_id, cx); } } - (Loadable::Ready(_status), DiffArea::Staged) => { + (true, DiffArea::Staged) => { this.store.dispatch(Msg::UnstagePath { repo_id, path: path.clone(), @@ -2982,13 +2974,13 @@ impl MainPaneView { this.clear_diff_selection_or_exit(repo_id, cx); } } - (_, DiffArea::Unstaged) => { + (false, DiffArea::Unstaged) => { this.store.dispatch(Msg::StagePath { repo_id, path: path.clone(), }); } - (_, DiffArea::Staged) => { + (false, DiffArea::Staged) => { this.store.dispatch(Msg::UnstagePath { repo_id, path: path.clone(), diff --git a/crates/gitcomet-ui-gpui/src/view/panels/main/diff_view_helpers.rs b/crates/gitcomet-ui-gpui/src/view/panels/main/diff_view_helpers.rs index 3a9939e7..c4c2e334 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/main/diff_view_helpers.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/main/diff_view_helpers.rs @@ -8,15 +8,9 @@ impl MainPaneView { let (icon, color, text): (Option<&'static str>, gpui::Rgba, SharedString) = match t { DiffTarget::WorkingTree { path, area } => { - let kind = self.active_repo().and_then(|repo| match &repo.status { - Loadable::Ready(status) => { - let list = match area { - DiffArea::Unstaged => &status.unstaged, - DiffArea::Staged => &status.staged, - }; - list.iter().find(|e| e.path == *path).map(|e| e.kind) - } - _ => None, + let kind = self.active_repo().and_then(|repo| { + repo.status_entry_for_path(*area, path.as_path()) + .map(|entry| entry.kind) }); let (icon, color) = match kind.unwrap_or(FileStatusKind::Modified) { @@ -102,18 +96,19 @@ impl MainPaneView { let repo = self.active_repo()?; let change_tracking_view = self.active_change_tracking_view(cx); - let (prev, next) = match &repo.status { - Loadable::Ready(status) => repo - .diff_state - .diff_target - .as_ref() - .and_then(|target| { - status_nav::status_navigation_context(status, target, change_tracking_view) - }) - .map(|navigation| (navigation.prev_ix(), navigation.next_ix())) - .unwrap_or((None, None)), - _ => (None, None), - }; + let (prev, next) = repo + .diff_state + .diff_target + .as_ref() + .and_then(|target| { + status_nav::status_navigation_context_for_repo( + repo, + target, + change_tracking_view, + ) + }) + .map(|navigation| (navigation.prev_ix(), navigation.next_ix())) + .unwrap_or((None, None)); let prev_disabled = prev.is_none(); let next_disabled = next.is_none(); diff --git a/crates/gitcomet-ui-gpui/src/view/panels/main/status_nav.rs b/crates/gitcomet-ui-gpui/src/view/panels/main/status_nav.rs index 4bdbddc7..bb3e977c 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/main/status_nav.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/main/status_nav.rs @@ -31,6 +31,7 @@ impl<'a> StatusNavigationContext<'a> { } } +#[cfg(test)] fn status_navigation_section_for_target( status: &gitcomet_core::domain::RepoStatus, change_tracking_view: ChangeTrackingView, @@ -56,6 +57,7 @@ fn status_navigation_section_for_target( } } +#[cfg(test)] fn status_navigation_entries_for_section( status: &gitcomet_core::domain::RepoStatus, section: StatusSection, @@ -76,6 +78,7 @@ fn status_navigation_entries_for_section( } } +#[cfg(test)] pub(super) fn status_navigation_context<'a>( status: &'a gitcomet_core::domain::RepoStatus, diff_target: &DiffTarget, @@ -95,6 +98,39 @@ pub(super) fn status_navigation_context<'a>( }) } +pub(super) fn status_navigation_context_for_repo<'a>( + repo: &'a RepoState, + diff_target: &DiffTarget, + change_tracking_view: ChangeTrackingView, +) -> Option> { + let DiffTarget::WorkingTree { path, area } = diff_target else { + return None; + }; + let section = match area { + DiffArea::Staged => StatusSection::Staged, + DiffArea::Unstaged => match change_tracking_view { + ChangeTrackingView::Combined => StatusSection::CombinedUnstaged, + ChangeTrackingView::SplitUntracked => { + let entry = repo.status_entry_for_path(DiffArea::Unstaged, path.as_path())?; + if entry.kind == gitcomet_core::domain::FileStatusKind::Untracked { + StatusSection::Untracked + } else { + StatusSection::Unstaged + } + } + }, + }; + let entries: Vec<_> = StatusSectionEntries::from_repo(repo, section)? + .iter() + .collect(); + let current_ix = entries.iter().position(|entry| entry.path == *path)?; + Some(StatusNavigationContext { + section, + entries, + current_ix, + }) +} + impl MainPaneView { pub(in crate::view) fn try_select_adjacent_status_file( &mut self, @@ -106,11 +142,9 @@ impl MainPaneView { let change_tracking_view = self.active_change_tracking_view(cx); let Some((section, area, target_ix, target_path, is_conflicted)) = (|| { let repo = self.active_repo()?; - let Loadable::Ready(status) = &repo.status else { - return None; - }; let diff_target = repo.diff_state.diff_target.as_ref()?; - let navigation = status_navigation_context(status, diff_target, change_tracking_view)?; + let navigation = + status_navigation_context_for_repo(repo, diff_target, change_tracking_view)?; let target_ix = navigation.adjacent_ix(direction)?; let entry = navigation.entries.get(target_ix)?; let target_path = entry.path.clone(); diff --git a/crates/gitcomet-ui-gpui/src/view/panels/mod.rs b/crates/gitcomet-ui-gpui/src/view/panels/mod.rs index 9599842f..d62d5c69 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/mod.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/mod.rs @@ -52,12 +52,6 @@ pub(in crate::view) enum ContextMenuAction { repo_id: RepoId, scope: gitcomet_core::domain::LogScope, }, - SetHistoryColumns { - show_author: bool, - show_date: bool, - show_sha: bool, - }, - ResetHistoryColumnWidths, SetChangeTrackingView { view: ChangeTrackingView, }, @@ -267,7 +261,7 @@ mod main; mod popover; mod repo_tabs_bar; -pub(super) use action_bar::ActionBarView; +pub(super) use action_bar::{ACTION_BAR_HEIGHT, ActionBarView}; pub(super) use popover::PopoverHost; pub(super) use repo_tabs_bar::RepoTabsBarView; #[allow(unused_imports)] diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover.rs index 2387d83c..69742758 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover.rs @@ -776,6 +776,26 @@ impl PopoverHost { self.open_popover(kind, PopoverAnchor::Bounds(anchor_bounds), window, cx); } + fn request_lazy_popover_repo_data(&self, kind: &PopoverKind) { + let repo_id = match kind { + PopoverKind::TagMenu { repo_id, .. } => Some(*repo_id), + _ => None, + }; + let Some(repo_id) = repo_id else { + return; + }; + let Some(repo) = self.state.repos.iter().find(|repo| repo.id == repo_id) else { + return; + }; + + if matches!(repo.tags, Loadable::NotLoaded | Loadable::Error(_)) { + self.store.dispatch(Msg::LoadTags { repo_id }); + } + if matches!(repo.remote_tags, Loadable::NotLoaded | Loadable::Error(_)) { + self.store.dispatch(Msg::LoadRemoteTags { repo_id }); + } + } + fn open_popover( &mut self, kind: PopoverKind, @@ -783,12 +803,12 @@ impl PopoverHost { window: &mut Window, cx: &mut gpui::Context, ) { + self.request_lazy_popover_repo_data(&kind); let is_context_menu = matches!( &kind, PopoverKind::PullPicker | PopoverKind::PushPicker | PopoverKind::HistoryBranchFilter { .. } - | PopoverKind::HistoryColumnSettings | PopoverKind::ChangeTrackingSettings | PopoverKind::DiffHunkMenu { .. } | PopoverKind::DiffEditorMenu { .. } @@ -1283,7 +1303,6 @@ impl PopoverHost { PopoverKind::PullPicker | PopoverKind::PushPicker | PopoverKind::HistoryBranchFilter { .. } - | PopoverKind::HistoryColumnSettings | PopoverKind::ChangeTrackingSettings | PopoverKind::DiffHunkMenu { .. } | PopoverKind::DiffEditorMenu { .. } @@ -1362,7 +1381,6 @@ impl PopoverHost { | PopoverKind::ForceRemoveWorktreeConfirm { .. } | PopoverKind::PullReconcilePrompt { .. } | PopoverKind::HistoryBranchFilter { .. } - | PopoverKind::HistoryColumnSettings | PopoverKind::ChangeTrackingSettings => Corner::TopRight, _ => Corner::TopLeft, }; @@ -1552,10 +1570,6 @@ impl PopoverHost { .context_menu_view(PopoverKind::HistoryBranchFilter { repo_id }, cx) .min_w(px(160.0)) .max_w(px(220.0)), - PopoverKind::HistoryColumnSettings => self - .context_menu_view(PopoverKind::HistoryColumnSettings, cx) - .min_w(px(160.0)) - .max_w(px(220.0)), PopoverKind::ChangeTrackingSettings => self .context_menu_view(PopoverKind::ChangeTrackingSettings, cx) .min_w(px(220.0)) diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu.rs index 98ce7ac0..5a8888f1 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu.rs @@ -11,7 +11,6 @@ mod conflict_resolver_output; mod diff_editor; mod diff_hunk; mod history_branch_filter; -mod history_column_settings; mod pull; mod push; mod remote; @@ -329,7 +328,6 @@ impl PopoverHost { PopoverKind::HistoryBranchFilter { repo_id } => { Some(history_branch_filter::model(*repo_id)) } - PopoverKind::HistoryColumnSettings => Some(history_column_settings::model(self, cx)), PopoverKind::ChangeTrackingSettings => Some(change_tracking_settings::model(self)), _ => None, } @@ -341,7 +339,7 @@ impl PopoverHost { window: &mut Window, cx: &mut gpui::Context, ) { - let mut close_after_action = true; + let close_after_action = true; match action { ContextMenuAction::SelectDiff { repo_id, target } => { self.store.dispatch(Msg::SelectDiff { repo_id, target }); @@ -470,31 +468,6 @@ impl PopoverHost { ContextMenuAction::SetHistoryScope { repo_id, scope } => { self.store.dispatch(Msg::SetHistoryScope { repo_id, scope }); } - ContextMenuAction::SetHistoryColumns { - show_author, - show_date, - show_sha, - } => { - self.main_pane.update(cx, |pane, cx| { - pane.history_view.update(cx, |view, cx| { - view.history_show_author = show_author; - view.history_show_date = show_date; - view.history_show_sha = show_sha; - cx.notify(); - }); - }); - self.schedule_ui_settings_persist(cx); - close_after_action = false; - } - ContextMenuAction::ResetHistoryColumnWidths => { - self.main_pane.update(cx, |pane, cx| { - pane.history_view.update(cx, |view, cx| { - view.reset_history_column_widths(); - cx.notify(); - }); - }); - close_after_action = false; - } ContextMenuAction::SetChangeTrackingView { view } => { self.change_tracking_view = view; let root_view = self.root_view.clone(); @@ -880,14 +853,10 @@ impl PopoverHost { .repos .iter() .find(|r| r.id == repo_id) - .and_then(|r| match &r.status { - Loadable::Ready(status) => status - .unstaged - .iter() - .chain(status.staged.iter()) - .find(|s| s.path == path) - .map(|s| s.kind), - _ => None, + .and_then(|repo| { + repo.status_entry_for_path(DiffArea::Unstaged, path.as_path()) + .or_else(|| repo.status_entry_for_path(DiffArea::Staged, path.as_path())) + .map(|status| status.kind) }) .is_some_and(|kind| matches!(kind, FileStatusKind::Untracked | FileStatusKind::Added)); diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/history_column_settings.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/history_column_settings.rs deleted file mode 100644 index fafe1b71..00000000 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/history_column_settings.rs +++ /dev/null @@ -1,81 +0,0 @@ -use super::*; - -pub(super) fn model(host: &PopoverHost, cx: &gpui::Context) -> ContextMenuModel { - let (show_author, show_date, show_sha) = host - .main_pane - .read(cx) - .history_visible_column_preferences(cx); - - model_for_preferences(show_author, show_date, show_sha) -} - -fn model_for_preferences(show_author: bool, show_date: bool, show_sha: bool) -> ContextMenuModel { - let check = |enabled: bool| enabled.then_some("icons/check.svg".into()); - - ContextMenuModel::new(vec![ - ContextMenuItem::Header("History columns".into()), - ContextMenuItem::Separator, - ContextMenuItem::Entry { - label: "Author".into(), - icon: check(show_author), - shortcut: Some("A".into()), - disabled: false, - action: Box::new(ContextMenuAction::SetHistoryColumns { - show_author: !show_author, - show_date, - show_sha, - }), - }, - ContextMenuItem::Entry { - label: "Commit date".into(), - icon: check(show_date), - shortcut: Some("D".into()), - disabled: false, - action: Box::new(ContextMenuAction::SetHistoryColumns { - show_author, - show_date: !show_date, - show_sha, - }), - }, - ContextMenuItem::Entry { - label: "SHA".into(), - icon: check(show_sha), - shortcut: Some("S".into()), - disabled: false, - action: Box::new(ContextMenuAction::SetHistoryColumns { - show_author, - show_date, - show_sha: !show_sha, - }), - }, - ContextMenuItem::Entry { - label: "Reset column widths".into(), - icon: Some("icons/refresh.svg".into()), - shortcut: Some("R".into()), - disabled: false, - action: Box::new(ContextMenuAction::ResetHistoryColumnWidths), - }, - ContextMenuItem::Separator, - ContextMenuItem::Label("Columns may auto-hide in narrow windows".into()), - ]) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn model_includes_reset_column_widths_entry() { - let model = super::model_for_preferences(true, true, true); - - let has_reset_entry = model.items.iter().any(|item| { - matches!( - item, - ContextMenuItem::Entry { label, action, .. } - if label.as_ref() == "Reset column widths" - && matches!(&**action, ContextMenuAction::ResetHistoryColumnWidths) - ) - }); - assert!(has_reset_entry); - } -} diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/status_file.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/status_file.rs index 82912167..1ec9e722 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/status_file.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/status_file.rs @@ -25,39 +25,32 @@ pub(super) fn model( .repos .iter() .find(|r| r.id == repo_id) - .and_then(|r| match &r.status { - Loadable::Ready(status) => { - let unstaged_kind = status - .unstaged - .iter() - .find(|s| &s.path == path) - .map(|s| s.kind); - let staged_kind = status - .staged - .iter() - .find(|s| &s.path == path) - .map(|s| s.kind); + .map(|repo| { + let unstaged_kind = repo + .status_entry_for_path(DiffArea::Unstaged, path.as_path()) + .map(|status| status.kind); + let staged_kind = repo + .status_entry_for_path(DiffArea::Staged, path.as_path()) + .map(|status| status.kind); - Some(( - matches!( - unstaged_kind, - Some(gitcomet_core::domain::FileStatusKind::Conflicted) - ) || matches!( - staged_kind, - Some(gitcomet_core::domain::FileStatusKind::Conflicted) - ), - matches!( - unstaged_kind, - Some(gitcomet_core::domain::FileStatusKind::Conflicted) - ), - unstaged_kind.is_some(), - matches!( - staged_kind, - Some(gitcomet_core::domain::FileStatusKind::Added) - ), - )) - } - _ => None, + ( + matches!( + unstaged_kind, + Some(gitcomet_core::domain::FileStatusKind::Conflicted) + ) || matches!( + staged_kind, + Some(gitcomet_core::domain::FileStatusKind::Conflicted) + ), + matches!( + unstaged_kind, + Some(gitcomet_core::domain::FileStatusKind::Conflicted) + ), + unstaged_kind.is_some(), + matches!( + staged_kind, + Some(gitcomet_core::domain::FileStatusKind::Added) + ), + ) }) .unwrap_or((false, false, false, false)); diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/tag.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/tag.rs index f480dfb9..8191c412 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/tag.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/context_menu/tag.rs @@ -6,12 +6,25 @@ pub(super) fn model(this: &PopoverHost, repo_id: RepoId, commit_id: &CommitId) - let short: SharedString = sha.get(0..8).unwrap_or(&sha).to_string().into(); let repo = this.state.repos.iter().find(|r| r.id == repo_id); - let tags = repo - .and_then(|r| match &r.tags { - Loadable::Ready(tags) => Some(tags.as_slice()), - _ => None, - }) - .unwrap_or(&[]); + let tags = match repo.map(|r| &r.tags) { + Some(Loadable::Ready(tags)) => Some(tags.as_slice()), + Some(Loadable::Error(err)) => { + return ContextMenuModel::new(vec![ + ContextMenuItem::Header(format!("Tags on {short}").into()), + ContextMenuItem::Separator, + ContextMenuItem::Label(err.clone().into()), + ]); + } + Some(Loadable::Loading) | Some(Loadable::NotLoaded) => { + return ContextMenuModel::new(vec![ + ContextMenuItem::Header(format!("Tags on {short}").into()), + ContextMenuItem::Separator, + ContextMenuItem::Label("Loading tags…".into()), + ]); + } + None => None, + } + .unwrap_or(&[]); let mut remote_names = repo .and_then(|r| match &r.remotes { Loadable::Ready(remotes) => Some( diff --git a/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs b/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs index 9c3f92c9..89c026f5 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/popover/fingerprint.rs @@ -83,7 +83,6 @@ fn repo_for_popover<'a>(state: &'a AppState, popover: &PopoverKind) -> Option<&' | PopoverKind::PushPicker | PopoverKind::AppMenu | PopoverKind::DiffHunks - | PopoverKind::HistoryColumnSettings | PopoverKind::ConflictResolverInputRowMenu { .. } | PopoverKind::ConflictResolverChunkMenu { .. } | PopoverKind::ConflictResolverOutputMenu { .. } => state.active_repo, @@ -160,7 +159,7 @@ fn hash_repo_for_popover(repo: &RepoState, popover: &PopoverKind, has PopoverKind::StashPrompt => { repo.stashes_rev.hash(hasher); - view_fingerprint::hash_loadable_arc(&repo.status, hasher); + repo.status_cache_rev().hash(hasher); } PopoverKind::StashDropConfirm { .. } | PopoverKind::StashMenu { .. } => { repo.stashes_rev.hash(hasher); @@ -189,7 +188,7 @@ fn hash_repo_for_popover(repo: &RepoState, popover: &PopoverKind, has repo.diff_state.diff_target, Some(DiffTarget::WorkingTree { .. }) ) { - view_fingerprint::hash_loadable_arc(&repo.status, hasher); + repo.status_cache_rev().hash(hasher); } } @@ -226,7 +225,6 @@ fn hash_repo_for_popover(repo: &RepoState, popover: &PopoverKind, has | PopoverKind::CommitMenu { .. } | PopoverKind::CommitFileMenu { .. } | PopoverKind::StatusFileMenu { .. } - | PopoverKind::HistoryColumnSettings | PopoverKind::ChangeTrackingSettings | PopoverKind::ConflictResolverInputRowMenu { .. } | PopoverKind::ConflictResolverChunkMenu { .. } @@ -460,7 +458,6 @@ fn hash_popover_kind(kind: &PopoverKind, hasher: &mut H) { 48u8.hash(hasher); repo_id.hash(hasher); } - PopoverKind::HistoryColumnSettings => 49u8.hash(hasher), PopoverKind::MergeAbortConfirm { repo_id } => { 51u8.hash(hasher); repo_id.hash(hasher); diff --git a/crates/gitcomet-ui-gpui/src/view/panels/repo_tabs_bar.rs b/crates/gitcomet-ui-gpui/src/view/panels/repo_tabs_bar.rs index aa17c559..83c00721 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/repo_tabs_bar.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/repo_tabs_bar.rs @@ -105,6 +105,10 @@ impl RepoTabsBarView { repo.missing_on_disk && !show_spinner } + fn repo_tab_click_message(active_repo: Option, repo_id: RepoId) -> Option { + (active_repo != Some(repo_id)).then_some(Msg::SetActiveRepo { repo_id }) + } + pub(in super::super) fn new( store: Arc, ui_model: Entity, @@ -455,7 +459,10 @@ impl Render for RepoTabsBarView { } })) .on_click(cx.listener(move |this, _e: &ClickEvent, _w, _cx| { - this.store.dispatch(Msg::SetActiveRepo { repo_id }); + if let Some(msg) = Self::repo_tab_click_message(this.active_repo_id(), repo_id) + { + this.store.dispatch(msg); + } })); bar = bar.tab(tab); @@ -566,6 +573,7 @@ mod tests { use super::{RepoTabsBarView, repo_tab_insert_before_for_drag_cursor}; use gitcomet_core::domain::RepoSpec; use gitcomet_state::model::{RepoId, RepoState}; + use gitcomet_state::msg::Msg; use std::path::PathBuf; fn repo_state(path: &str) -> RepoState { @@ -631,4 +639,17 @@ mod tests { None ); } + + #[test] + fn repo_tab_click_message_is_none_for_active_repo() { + assert!(RepoTabsBarView::repo_tab_click_message(Some(RepoId(5)), RepoId(5)).is_none()); + } + + #[test] + fn repo_tab_click_message_activates_inactive_repo() { + assert!(matches!( + RepoTabsBarView::repo_tab_click_message(Some(RepoId(5)), RepoId(6)), + Some(Msg::SetActiveRepo { repo_id: RepoId(6) }) + )); + } } diff --git a/crates/gitcomet-ui-gpui/src/view/panels/tests/file_diff.rs b/crates/gitcomet-ui-gpui/src/view/panels/tests/file_diff.rs index 9b50ae99..8060498e 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/tests/file_diff.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/tests/file_diff.rs @@ -4274,6 +4274,19 @@ fn yaml_file_diff_keeps_consistent_highlighting_for_added_paths_and_keys( Some((styled.text.as_ref(), styled)) } + fn force_file_diff_fallback_mode(pane: &mut MainPaneView) { + pane.file_diff_syntax_generation = pane.file_diff_syntax_generation.wrapping_add(1); + for view_mode in [ + PreparedSyntaxViewMode::FileDiffSplitLeft, + PreparedSyntaxViewMode::FileDiffSplitRight, + ] { + if let Some(key) = pane.file_diff_prepared_syntax_key(view_mode) { + pane.prepared_syntax_documents.remove(&key); + } + } + pane.clear_diff_text_style_caches(); + } + fn quoted_scalar_style( styled: &super::CachedDiffStyledText, text: &str, @@ -4701,7 +4714,7 @@ fn yaml_file_diff_keeps_consistent_highlighting_for_added_paths_and_keys( wait_for_main_pane_condition( cx, &view, - "YAML file-diff cache build before syntax rows are ready", + "YAML file-diff cache build before fallback highlighting checks", |pane| { pane.file_diff_cache_inflight.is_none() && pane.file_diff_cache_rev == 0 @@ -4731,6 +4744,56 @@ fn yaml_file_diff_keeps_consistent_highlighting_for_added_paths_and_keys( }, ); + cx.update(|_window, app| { + view.update(app, |this, cx| { + this.main_pane.update(cx, |pane, cx| { + // Other YAML tests can warm the shared prepared-syntax cache before this + // test runs. Clear the local prepared documents and invalidate any in-flight + // background parse so the next draw deterministically exercises fallback mode. + force_file_diff_fallback_mode(pane); + cx.notify(); + }); + }); + }); + + wait_for_main_pane_condition( + cx, + &view, + "YAML file-diff fallback mode forced for highlight checks", + |pane| { + pane.file_diff_cache_rev == 0 + && pane.file_diff_cache_path == Some(workdir.join(&path)) + && pane.file_diff_cache_language == Some(rows::DiffSyntaxLanguage::Yaml) + && pane + .file_diff_split_prepared_syntax_document(DiffTextRegion::SplitLeft) + .is_none() + && pane + .file_diff_split_prepared_syntax_document(DiffTextRegion::SplitRight) + .is_none() + && pane + .file_diff_cache_rows + .iter() + .any(|row| row.new_line == Some(36)) + && pane + .file_diff_inline_cache + .iter() + .any(|line| line.new_line == Some(36)) + }, + |pane| { + format!( + "rev={} inflight={:?} cache_path={:?} language={:?} left_doc={:?} right_doc={:?} rows={} inline_rows={}", + pane.file_diff_cache_rev, + pane.file_diff_cache_inflight, + pane.file_diff_cache_path.clone(), + pane.file_diff_cache_language, + pane.file_diff_split_prepared_syntax_document(DiffTextRegion::SplitLeft), + pane.file_diff_split_prepared_syntax_document(DiffTextRegion::SplitRight), + pane.file_diff_cache_rows.len(), + pane.file_diff_inline_cache.len(), + ) + }, + ); + cx.update(|_window, app| { view.update(app, |this, cx| { this.main_pane.update(cx, |pane, cx| { diff --git a/crates/gitcomet-ui-gpui/src/view/panels/tests/file_status.rs b/crates/gitcomet-ui-gpui/src/view/panels/tests/file_status.rs index 3c343fc1..8585ea59 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/tests/file_status.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/tests/file_status.rs @@ -1783,6 +1783,62 @@ fn merge_start_prefills_default_commit_message(cx: &mut gpui::TestAppContext) { }); } +#[gpui::test] +fn commit_message_focus_after_initial_draw_accepts_typed_input(cx: &mut gpui::TestAppContext) { + let (store, events) = AppStore::new(Arc::new(TestBackend)); + let (view, cx) = cx.add_window_view(|window, cx| { + super::super::GitCometView::new(store, events, None, window, cx) + }); + + let repo_id = gitcomet_state::model::RepoId(44); + let make_state = || { + let mut repo = opening_repo_state(repo_id, Path::new("/tmp/repo-commit-message-focus")); + repo.status = gitcomet_state::model::Loadable::Ready( + gitcomet_core::domain::RepoStatus { + staged: vec![gitcomet_core::domain::FileStatus { + path: std::path::PathBuf::from("staged.txt"), + kind: gitcomet_core::domain::FileStatusKind::Modified, + conflict: None, + }], + unstaged: Vec::new(), + } + .into(), + ); + app_state_with_repo(repo, repo_id) + }; + + cx.update(|window, app| { + view.update(app, |this, cx| { + push_test_state(this, make_state(), cx); + }); + let _ = window.draw(app); + }); + + cx.update(|window, app| { + view.update(app, |this, cx| { + this.details_pane.update(cx, |pane, cx| { + let focus = pane.commit_message_input.read(cx).focus_handle(); + window.focus(&focus, cx); + }); + }); + let _ = window.draw(app); + }); + + cx.simulate_input("x"); + + let text = cx.update(|window, app| { + let _ = window.draw(app); + view.read(app) + .details_pane + .read(app) + .commit_message_input + .read(app) + .text() + .to_string() + }); + assert_eq!(text, "x"); +} + #[gpui::test] fn commit_click_dispatches_after_state_update_without_intermediate_redraw( cx: &mut gpui::TestAppContext, diff --git a/crates/gitcomet-ui-gpui/src/view/panels/tests/shortcuts.rs b/crates/gitcomet-ui-gpui/src/view/panels/tests/shortcuts.rs index fbba0071..78269925 100644 --- a/crates/gitcomet-ui-gpui/src/view/panels/tests/shortcuts.rs +++ b/crates/gitcomet-ui-gpui/src/view/panels/tests/shortcuts.rs @@ -299,43 +299,6 @@ fn history_context_menu_shortcuts_match_expected_actions(cx: &mut gpui::TestAppC }); }); - let history_columns_model = cx.update(|_window, app| { - context_menu_model_for(&view, app, PopoverKind::HistoryColumnSettings) - }); - assert_declared_shortcuts(&history_columns_model, &["A", "D", "S", "R"]); - assert_shortcut_action!( - history_columns_model, - "A", - ContextMenuAction::SetHistoryColumns { - show_author, - show_date, - show_sha - } if !*show_author && *show_date && *show_sha - ); - assert_shortcut_action!( - history_columns_model, - "D", - ContextMenuAction::SetHistoryColumns { - show_author, - show_date, - show_sha - } if *show_author && !*show_date && *show_sha - ); - assert_shortcut_action!( - history_columns_model, - "S", - ContextMenuAction::SetHistoryColumns { - show_author, - show_date, - show_sha - } if *show_author && *show_date && !*show_sha - ); - assert_shortcut_action!( - history_columns_model, - "R", - ContextMenuAction::ResetHistoryColumnWidths - ); - let change_tracking_model = cx.update(|_window, app| { context_menu_model_for(&view, app, PopoverKind::ChangeTrackingSettings) }); diff --git a/crates/gitcomet-ui-gpui/src/view/panes/details.rs b/crates/gitcomet-ui-gpui/src/view/panes/details.rs index 4f10938b..e313c6f1 100644 --- a/crates/gitcomet-ui-gpui/src/view/panes/details.rs +++ b/crates/gitcomet-ui-gpui/src/view/panes/details.rs @@ -40,7 +40,7 @@ pub(in super::super) struct DetailsPaneView { pub(in super::super) commit_message_programmatic_change: bool, pub(in super::super) status_multi_selection: HashMap, - pub(in super::super) status_multi_selection_last_status: HashMap>, + pub(in super::super) status_multi_selection_last_status: HashMap, pub(in super::super) commit_details_delay: Option, pub(in super::super) commit_details_delay_seq: u64, @@ -160,7 +160,8 @@ impl DetailsPaneView { if let Some(repo_id) = state.active_repo && let Some(repo) = state.repos.iter().find(|r| r.id == repo_id) { - repo.status_rev.hash(&mut hasher); + repo.worktree_status_cache_rev().hash(&mut hasher); + repo.staged_status_cache_rev().hash(&mut hasher); repo.ops_rev.hash(&mut hasher); repo.history_state.selected_commit_rev.hash(&mut hasher); repo.history_state.commit_details_rev.hash(&mut hasher); @@ -464,17 +465,17 @@ impl DetailsPaneView { return false; } - let Loadable::Ready(status) = &repo.status else { - return true; - }; - + let status_key = ( + repo.worktree_status_cache_rev(), + repo.staged_status_cache_rev(), + ); let status_changed = match last_status.get(repo_id) { - Some(prev) => !Arc::ptr_eq(prev, status), + Some(prev) => *prev != status_key, None => true, }; if status_changed { - last_status.insert(*repo_id, Arc::clone(status)); - reconcile_status_multi_selection(selection, status); + last_status.insert(*repo_id, status_key); + reconcile_status_multi_selection_with_repo(selection, repo); } if selection.is_empty() { @@ -735,7 +736,8 @@ mod tests { let initial = DetailsPaneView::notify_fingerprint(&state); - state.repos[1].status_rev = 1; + state.repos[1].worktree_status_rev = 1; + state.repos[1].staged_status_rev = 1; state.repos[1].ops_rev = 1; state.repos[1].history_state.selected_commit_rev = 1; state.repos[1].history_state.commit_details_rev = 1; @@ -754,7 +756,7 @@ mod tests { let initial = DetailsPaneView::notify_fingerprint(&state); - state.repos[0].status_rev = 1; + state.repos[0].worktree_status_rev = 1; let after_status = DetailsPaneView::notify_fingerprint(&state); assert_ne!(after_status, initial); diff --git a/crates/gitcomet-ui-gpui/src/view/panes/history.rs b/crates/gitcomet-ui-gpui/src/view/panes/history.rs index 2c663370..e0ead58d 100644 --- a/crates/gitcomet-ui-gpui/src/view/panes/history.rs +++ b/crates/gitcomet-ui-gpui/src/view/panes/history.rs @@ -67,6 +67,7 @@ fn default_history_column_widths() -> HistoryColumnWidths { #[derive(Copy, Clone)] pub(in crate::view) struct HistoryColumnDragLayout { + pub(in crate::view) show_graph: bool, pub(in crate::view) show_author: bool, pub(in crate::view) show_date: bool, pub(in crate::view) show_sha: bool, @@ -79,6 +80,7 @@ pub(in crate::view) struct HistoryColumnDragLayout { fn history_visible_columns_for_width( available_width: Pixels, + show_graph: bool, preferred: (bool, bool, bool), widths: HistoryColumnWidths, ) -> (bool, bool, bool) { @@ -90,8 +92,7 @@ fn history_visible_columns_for_width( let (mut show_author, mut show_date, mut show_sha) = preferred; - // Always show Branch + Graph; Message is flex. - let fixed_base = widths.branch + widths.graph; + let fixed_base = widths.branch + if show_graph { widths.graph } else { px(0.0) }; let mut fixed = fixed_base + if show_author { widths.author } else { px(0.0) } + if show_date { widths.date } else { px(0.0) } @@ -126,16 +127,18 @@ fn history_column_drag_next_width( handle: HistoryColResizeHandle, candidate: Pixels, available_width: Pixels, + show_graph: bool, preferred: (bool, bool, bool), widths: HistoryColumnWidths, ) -> Pixels { let (show_author, show_date, show_sha) = - history_visible_columns_for_width(available_width, preferred, widths); + history_visible_columns_for_width(available_width, show_graph, preferred, widths); history_column_drag_clamped_width( handle, candidate, available_width, HistoryColumnDragLayout { + show_graph, show_author, show_date, show_sha, @@ -150,6 +153,7 @@ fn history_column_drag_next_width( fn history_reset_widths_for_available_width( available_width: Pixels, + show_graph: bool, preferred: (bool, bool, bool), ) -> HistoryColumnWidths { let mut widths = default_history_column_widths(); @@ -157,6 +161,7 @@ fn history_reset_widths_for_available_width( HistoryColResizeHandle::Graph, widths.graph, available_width, + show_graph, preferred, widths, ); @@ -164,6 +169,7 @@ fn history_reset_widths_for_available_width( HistoryColResizeHandle::Branch, widths.branch, available_width, + show_graph, preferred, widths, ); @@ -193,7 +199,26 @@ pub(in crate::view) fn history_column_resize_drag_params( let (min_width, static_max_width) = history_column_static_bounds(handle); let other_fixed_width = match handle { HistoryColResizeHandle::Branch => { - layout.graph_w + (if layout.show_graph { + layout.graph_w + } else { + px(0.0) + }) + if layout.show_author { + layout.author_w + } else { + px(0.0) + } + if layout.show_date { + layout.date_w + } else { + px(0.0) + } + if layout.show_sha { + layout.sha_w + } else { + px(0.0) + } + } + HistoryColResizeHandle::Graph => { + layout.branch_w + if layout.show_author { layout.author_w } else { @@ -210,10 +235,10 @@ pub(in crate::view) fn history_column_resize_drag_params( px(0.0) } } - HistoryColResizeHandle::Graph => { + HistoryColResizeHandle::Author => { layout.branch_w - + if layout.show_author { - layout.author_w + + if layout.show_graph { + layout.graph_w } else { px(0.0) } @@ -228,23 +253,13 @@ pub(in crate::view) fn history_column_resize_drag_params( px(0.0) } } - HistoryColResizeHandle::Author => { + HistoryColResizeHandle::Date => { layout.branch_w - + layout.graph_w - + if layout.show_date { - layout.date_w - } else { - px(0.0) - } - + if layout.show_sha { - layout.sha_w + + if layout.show_graph { + layout.graph_w } else { px(0.0) } - } - HistoryColResizeHandle::Date => { - layout.branch_w - + layout.graph_w + if layout.show_author { layout.author_w } else { @@ -258,7 +273,11 @@ pub(in crate::view) fn history_column_resize_drag_params( } HistoryColResizeHandle::Sha => { layout.branch_w - + layout.graph_w + + if layout.show_graph { + layout.graph_w + } else { + px(0.0) + } + if layout.show_author { layout.author_w } else { @@ -453,7 +472,12 @@ pub(in crate::view) fn history_visible_columns_for_layout( let mut show_date = layout.show_date; let mut show_sha = layout.show_sha; - let fixed_base = layout.branch_w + layout.graph_w; + let fixed_base = layout.branch_w + + if layout.show_graph { + layout.graph_w + } else { + px(0.0) + }; let mut fixed = fixed_base + if show_author { layout.author_w @@ -721,9 +745,12 @@ pub(in super::super) struct HistoryView { pub(in super::super) history_col_author: Pixels, pub(in super::super) history_col_date: Pixels, pub(in super::super) history_col_sha: Pixels, + pub(in super::super) history_show_graph: bool, pub(in super::super) history_show_author: bool, pub(in super::super) history_show_date: bool, pub(in super::super) history_show_sha: bool, + pub(in super::super) history_show_tags: bool, + pub(in super::super) history_auto_fetch_tags_on_repo_activation: bool, pub(in super::super) history_col_graph_auto: bool, pub(in super::super) history_col_resize: Option, pub(in super::super) history_cache: Option, @@ -737,7 +764,7 @@ pub(in super::super) struct HistoryView { } impl HistoryView { - fn notify_fingerprint_for(state: &AppState) -> u64 { + fn notify_fingerprint_for(state: &AppState, show_history_tags: bool) -> u64 { let mut hasher = FxHasher::default(); state.active_repo.hash(&mut hasher); @@ -749,10 +776,13 @@ impl HistoryView { repo.detached_head_commit.hash(&mut hasher); repo.branches_rev.hash(&mut hasher); repo.remote_branches_rev.hash(&mut hasher); - repo.tags_rev.hash(&mut hasher); + if show_history_tags { + repo.tags_rev.hash(&mut hasher); + } repo.stashes_rev.hash(&mut hasher); repo.history_state.selected_commit_rev.hash(&mut hasher); - repo.status_rev.hash(&mut hasher); + repo.worktree_status_cache_rev().hash(&mut hasher); + repo.staged_status_cache_rev().hash(&mut hasher); } hasher.finish() @@ -766,9 +796,12 @@ impl HistoryView { date_time_format: DateTimeFormat, timezone: Timezone, show_timezone: bool, + history_show_graph: bool, history_show_author: bool, history_show_date: bool, history_show_sha: bool, + history_show_tags: bool, + history_auto_fetch_tags_on_repo_activation: bool, root_view: WeakEntity, tooltip_host: WeakEntity, last_window_size: Size, @@ -776,10 +809,10 @@ impl HistoryView { cx: &mut gpui::Context, ) -> Self { let state = Arc::clone(&ui_model.read(cx).state); - let initial_fingerprint = Self::notify_fingerprint_for(&state); + let initial_fingerprint = Self::notify_fingerprint_for(&state, history_show_tags); let subscription = cx.observe(&ui_model, |this, model, cx| { let next = Arc::clone(&model.read(cx).state); - let next_fingerprint = Self::notify_fingerprint_for(&next); + let next_fingerprint = Self::notify_fingerprint_for(&next, this.history_show_tags); if next_fingerprint == this.notify_fingerprint { this.state = next; return; @@ -814,9 +847,12 @@ impl HistoryView { history_col_author: default_widths.author, history_col_date: default_widths.date, history_col_sha: default_widths.sha, + history_show_graph, history_show_author, history_show_date, history_show_sha, + history_show_tags, + history_auto_fetch_tags_on_repo_activation, history_col_graph_auto: true, history_col_resize: None, history_cache: None, @@ -852,7 +888,11 @@ impl HistoryView { detached_head_commit: repo.detached_head_commit.clone(), branches_rev: repo.branches_rev, remote_branches_rev: repo.remote_branches_rev, - tags_rev: repo.tags_rev, + tags_rev: if self.history_show_tags { + repo.tags_rev + } else { + Default::default() + }, stashes_rev: repo.stashes_rev, date_time_format: self.date_time_format, timezone: self.timezone, @@ -912,17 +952,19 @@ impl HistoryView { ) } - pub(in super::super) fn history_visible_column_preferences(&self) -> (bool, bool, bool) { + pub(in super::super) fn history_visible_column_preferences(&self) -> (bool, bool, bool, bool) { ( + self.history_show_graph, self.history_show_author, self.history_show_date, self.history_show_sha, ) } - pub(in super::super) fn history_visible_columns(&self) -> (bool, bool, bool) { + pub(in super::super) fn history_visible_columns(&self) -> (bool, bool, bool, bool) { let available = self.history_content_width; let layout = HistoryColumnDragLayout { + show_graph: self.history_show_graph, show_author: self.history_show_author, show_date: self.history_show_date, show_sha: self.history_show_sha, @@ -932,17 +974,24 @@ impl HistoryView { date_w: self.history_col_date, sha_w: self.history_col_sha, }; - history_visible_columns_for_layout_with_resize_state( - available, - layout, - self.history_col_resize.as_ref(), - ) + let (show_author, show_date, show_sha) = + history_visible_columns_for_layout_with_resize_state( + available, + layout, + self.history_col_resize.as_ref(), + ); + (self.history_show_graph, show_author, show_date, show_sha) } pub(in super::super) fn reset_history_column_widths(&mut self) { let widths = history_reset_widths_for_available_width( self.history_content_width, - self.history_visible_column_preferences(), + self.history_show_graph, + ( + self.history_show_author, + self.history_show_date, + self.history_show_sha, + ), ); self.history_col_branch = widths.branch; self.history_col_graph = widths.graph; @@ -1021,6 +1070,60 @@ impl HistoryView { cx.notify(); } + pub(in super::super) fn history_tag_preferences(&self) -> (bool, bool) { + ( + self.history_show_tags, + self.history_auto_fetch_tags_on_repo_activation, + ) + } + + pub(in super::super) fn set_history_column_preferences( + &mut self, + show_graph: bool, + show_author: bool, + show_date: bool, + show_sha: bool, + cx: &mut gpui::Context, + ) { + if self.history_show_graph == show_graph + && self.history_show_author == show_author + && self.history_show_date == show_date + && self.history_show_sha == show_sha + { + return; + } + + self.history_show_graph = show_graph; + self.history_show_author = show_author; + self.history_show_date = show_date; + self.history_show_sha = show_sha; + self.history_col_resize = None; + cx.notify(); + } + + pub(in super::super) fn set_history_tag_preferences( + &mut self, + show_tags: bool, + auto_fetch_tags_on_repo_activation: bool, + cx: &mut gpui::Context, + ) { + if self.history_show_tags == show_tags + && self.history_auto_fetch_tags_on_repo_activation == auto_fetch_tags_on_repo_activation + { + return; + } + + let show_tags_changed = self.history_show_tags != show_tags; + self.history_show_tags = show_tags; + self.history_auto_fetch_tags_on_repo_activation = auto_fetch_tags_on_repo_activation; + if show_tags_changed { + self.notify_fingerprint = Self::notify_fingerprint_for(&self.state, show_tags); + self.history_cache = None; + self.history_cache_inflight = None; + } + cx.notify(); + } + pub(in super::super) fn set_last_window_size(&mut self, size: Size) { self.last_window_size = size; } @@ -1263,7 +1366,8 @@ impl HistoryView { }, Rebuild { repo_id: RepoId, - status: Arc, + worktree_status_rev: u64, + staged_status_rev: u64, show_row: bool, counts: (usize, usize, usize), }, @@ -1273,13 +1377,19 @@ impl HistoryView { let Some(repo) = self.active_repo() else { return Action::Clear; }; - let Loadable::Ready(status) = &repo.status else { + let worktree = repo.worktree_status_entries(); + let staged = repo.staged_status_entries(); + if worktree.is_none() && staged.is_none() { return Action::Clear; - }; + } + + let worktree_status_rev = repo.worktree_status_cache_rev(); + let staged_status_rev = repo.staged_status_cache_rev(); if let Some(cache) = &self.history_worktree_summary_cache && cache.repo_id == repo.id - && Arc::ptr_eq(&cache.status, status) + && cache.worktree_status_rev == worktree_status_rev + && cache.staged_status_rev == staged_status_rev { return Action::CacheOk { show_row: cache.show_row, @@ -1303,9 +1413,10 @@ impl HistoryView { (added, modified, deleted) }; - let unstaged_counts = count_for(&status.unstaged); - let staged_counts = count_for(&status.staged); - let show_row = !status.unstaged.is_empty() || !status.staged.is_empty(); + let unstaged_counts = worktree.map_or((0, 0, 0), count_for); + let staged_counts = staged.map_or((0, 0, 0), count_for); + let show_row = worktree.is_some_and(|entries| !entries.is_empty()) + || staged.is_some_and(|entries| !entries.is_empty()); let counts = ( unstaged_counts.0 + staged_counts.0, unstaged_counts.1 + staged_counts.1, @@ -1314,7 +1425,8 @@ impl HistoryView { Action::Rebuild { repo_id: repo.id, - status: Arc::clone(status), + worktree_status_rev, + staged_status_rev, show_row, counts, } @@ -1328,13 +1440,15 @@ impl HistoryView { Action::CacheOk { show_row, counts } => (show_row, counts), Action::Rebuild { repo_id, - status, + worktree_status_rev, + staged_status_rev, show_row, counts, } => { self.history_worktree_summary_cache = Some(HistoryWorktreeSummaryCache { repo_id, - status, + worktree_status_rev, + staged_status_rev, show_row, counts, }); @@ -1449,9 +1563,13 @@ impl HistoryView { Loadable::Ready(b) => Arc::clone(b), _ => Arc::new(Vec::new()), }, - tags: match &repo.tags { - Loadable::Ready(t) => Arc::clone(t), - _ => Arc::new(Vec::new()), + tags: if self.history_show_tags { + match &repo.tags { + Loadable::Ready(t) => Arc::clone(t), + _ => Arc::new(Vec::new()), + } + } else { + Arc::new(Vec::new()) }, stashes: match &repo.stashes { Loadable::Ready(s) => Arc::clone(s), @@ -1713,19 +1831,26 @@ impl HistoryView { if this.history_col_graph_auto && this.history_col_resize.is_none() { let required = px(HISTORY_GRAPH_MARGIN_X_PX * 2.0 + HISTORY_GRAPH_COL_GAP_PX * (rebuild.max_lanes as f32)); - this.history_col_graph = history_column_drag_next_width( - HistoryColResizeHandle::Graph, - required.min(px(HISTORY_COL_GRAPH_MAX_PX)), - this.history_content_width, - this.history_visible_column_preferences(), - HistoryColumnWidths { - branch: this.history_col_branch, - graph: this.history_col_graph, - author: this.history_col_author, - date: this.history_col_date, - sha: this.history_col_sha, - }, - ); + if this.history_show_graph { + this.history_col_graph = history_column_drag_next_width( + HistoryColResizeHandle::Graph, + required.min(px(HISTORY_COL_GRAPH_MAX_PX)), + this.history_content_width, + this.history_show_graph, + ( + this.history_show_author, + this.history_show_date, + this.history_show_sha, + ), + HistoryColumnWidths { + branch: this.history_col_branch, + graph: this.history_col_graph, + author: this.history_col_author, + date: this.history_col_date, + sha: this.history_col_sha, + }, + ); + } } this.history_cache_inflight = None; @@ -1788,6 +1913,7 @@ mod tests { fn all_columns_visible_drag_layout() -> HistoryColumnDragLayout { HistoryColumnDragLayout { + show_graph: true, show_author: true, show_date: true, show_sha: true, @@ -1974,7 +2100,7 @@ mod tests { let preferred = (true, true, true); assert_eq!( - history_visible_columns_for_width(available, preferred, widths), + history_visible_columns_for_width(available, true, preferred, widths), (false, false, false) ); @@ -1982,6 +2108,7 @@ mod tests { HistoryColResizeHandle::Graph, px(90.0), available, + true, preferred, widths, ); @@ -1991,7 +2118,7 @@ mod tests { #[test] fn reset_widths_clamp_default_graph_in_narrow_windows() { - let widths = history_reset_widths_for_available_width(px(396.0), (true, true, true)); + let widths = history_reset_widths_for_available_width(px(396.0), true, (true, true, true)); assert_eq!(widths.branch, px(HISTORY_COL_BRANCH_PX)); assert_eq!(widths.graph, px(46.0)); @@ -1999,7 +2126,7 @@ mod tests { #[test] fn reset_widths_clamp_branch_after_graph_reaches_minimum() { - let widths = history_reset_widths_for_available_width(px(360.0), (true, true, true)); + let widths = history_reset_widths_for_available_width(px(360.0), true, (true, true, true)); assert_eq!(widths.graph, px(HISTORY_COL_GRAPH_MIN_PX)); assert_eq!(widths.branch, px(96.0)); diff --git a/crates/gitcomet-ui-gpui/src/view/panes/history/history_panel.rs b/crates/gitcomet-ui-gpui/src/view/panes/history/history_panel.rs index 738d4910..6ab0908e 100644 --- a/crates/gitcomet-ui-gpui/src/view/panes/history/history_panel.rs +++ b/crates/gitcomet-ui-gpui/src/view/panes/history/history_panel.rs @@ -272,7 +272,7 @@ impl HistoryView { fn history_column_headers(&mut self, cx: &mut gpui::Context) -> gpui::Div { let theme = self.theme; let icon_muted = with_alpha(theme.colors.accent, if theme.is_dark { 0.72 } else { 0.82 }); - let (show_author, show_date, show_sha) = self.history_visible_columns(); + let (show_graph, show_author, show_date, show_sha) = self.history_visible_columns(); let col_author = self.history_col_author; let col_date = self.history_col_date; let col_sha = self.history_col_sha; @@ -296,73 +296,6 @@ impl HistoryView { .active_context_menu_invoker .as_ref() .is_some_and(|id| id.as_ref() == scope_invoker.as_ref()); - let column_settings_invoker: SharedString = "history_columns_settings_btn".into(); - let column_settings_anchor_bounds: Rc>>> = - Rc::new(RefCell::new(None)); - let column_settings_anchor_bounds_for_prepaint = Rc::clone(&column_settings_anchor_bounds); - let column_settings_anchor_bounds_for_click = Rc::clone(&column_settings_anchor_bounds); - let column_settings_active = - self.active_context_menu_invoker.as_ref() == Some(&column_settings_invoker); - let open_column_settings = { - let column_settings_invoker = column_settings_invoker.clone(); - cx.listener(move |this, e: &ClickEvent, window, cx| { - this.activate_context_menu_invoker(column_settings_invoker.clone(), cx); - if let Some(bounds) = *column_settings_anchor_bounds_for_click.borrow() { - this.open_popover_for_bounds( - PopoverKind::HistoryColumnSettings, - bounds, - window, - cx, - ); - } else { - this.open_popover_at( - PopoverKind::HistoryColumnSettings, - e.position(), - window, - cx, - ); - } - }) - }; - let column_settings_btn_inner = div() - .id("history_columns_settings_btn") - .flex() - .items_center() - .justify_center() - .w(px(18.0)) - .h(px(18.0)) - .rounded(px(theme.radii.row)) - .when(column_settings_active, |d| d.bg(theme.colors.active)) - .hover(move |s| { - if column_settings_active { - s.bg(theme.colors.active) - } else { - s.bg(with_alpha(theme.colors.hover, 0.55)) - } - }) - .active(move |s| s.bg(theme.colors.active)) - .cursor(CursorStyle::PointingHand) - .child(svg_icon("icons/cog.svg", icon_muted, px(12.0))) - .on_click(open_column_settings) - .on_hover(cx.listener(move |this, hovering: &bool, _w, cx| { - let text: SharedString = "History columns".into(); - let mut changed = false; - if *hovering { - changed |= this.set_tooltip_text_if_changed(Some(text.clone()), cx); - } else { - changed |= this.clear_tooltip_if_matches(&text, cx); - } - if changed { - cx.notify(); - } - })); - let column_settings_btn = div() - .on_children_prepainted(move |children_bounds, _w, _cx| { - if let Some(bounds) = children_bounds.first() { - *column_settings_anchor_bounds_for_prepaint.borrow_mut() = Some(*bounds); - } - }) - .child(column_settings_btn_inner); let resize_handle = |id: &'static str, handle: HistoryColResizeHandle| { div() @@ -390,6 +323,7 @@ impl HistoryView { } let available_width = this.history_content_width; let drag_layout = super::HistoryColumnDragLayout { + show_graph: this.history_show_graph, show_author: this.history_show_author, show_date: this.history_show_date, show_sha: this.history_show_sha, @@ -560,23 +494,23 @@ impl HistoryView { ), ), ) - .child( - div() - .w(self.history_col_graph) - .flex() - .justify_center() - .px(cell_pad) - .whitespace_nowrap() - .overflow_hidden() - .child("GRAPH"), - ) + .when(show_graph, |header| { + header.child( + div() + .w(self.history_col_graph) + .flex() + .justify_center() + .px(cell_pad) + .whitespace_nowrap() + .overflow_hidden() + .child("GRAPH"), + ) + }) .child( div() .flex_1() .min_w(px(0.0)) .flex() - .items_center() - .justify_between() .px(cell_pad) .whitespace_nowrap() .overflow_hidden() @@ -587,8 +521,7 @@ impl HistoryView { .line_clamp(1) .whitespace_nowrap() .child("COMMIT MESSAGE"), - ) - .child(column_settings_btn), + ), ) .when(show_author, |header| { header.child( @@ -634,16 +567,18 @@ impl HistoryView { ); } - let mut header_with_handles = header - .child( - resize_handle("history_col_resize_branch", HistoryColResizeHandle::Branch) - .left((self.history_col_branch - handle_half).max(px(0.0))), - ) - .child( + let mut header_with_handles = header.child( + resize_handle("history_col_resize_branch", HistoryColResizeHandle::Branch) + .left((self.history_col_branch - handle_half).max(px(0.0))), + ); + + if show_graph { + header_with_handles = header_with_handles.child( resize_handle("history_col_resize_graph", HistoryColResizeHandle::Graph).left( (self.history_col_branch + self.history_col_graph - handle_half).max(px(0.0)), ), ); + } if show_author { let right_fixed = col_author diff --git a/crates/gitcomet-ui-gpui/src/view/panes/main/actions_impl.rs b/crates/gitcomet-ui-gpui/src/view/panes/main/actions_impl.rs index 3bbdcbc7..e9a83ecf 100644 --- a/crates/gitcomet-ui-gpui/src/view/panes/main/actions_impl.rs +++ b/crates/gitcomet-ui-gpui/src/view/panes/main/actions_impl.rs @@ -803,12 +803,9 @@ impl MainPaneView { return; } - let conflict_entry = match &repo.status { - Loadable::Ready(status) => status.unstaged.iter().find(|e| { - e.path == *path && e.kind == gitcomet_core::domain::FileStatusKind::Conflicted - }), - _ => None, - }; + let conflict_entry = repo + .status_entry_for_path(DiffArea::Unstaged, path.as_path()) + .filter(|entry| entry.kind == gitcomet_core::domain::FileStatusKind::Conflicted); let Some(conflict_entry) = conflict_entry else { self.clear_conflict_resolver_state(); return; diff --git a/crates/gitcomet-ui-gpui/src/view/panes/main/core_impl.rs b/crates/gitcomet-ui-gpui/src/view/panes/main/core_impl.rs index 8c25ed10..80b881a6 100644 --- a/crates/gitcomet-ui-gpui/src/view/panes/main/core_impl.rs +++ b/crates/gitcomet-ui-gpui/src/view/panes/main/core_impl.rs @@ -630,7 +630,7 @@ impl MainPaneView { repo.diff_state.diff_target, Some(DiffTarget::WorkingTree { .. }) ) { - repo.status_rev + repo.status_cache_rev() } else { 0 }; @@ -790,9 +790,12 @@ impl MainPaneView { timezone: Timezone, show_timezone: bool, diff_scroll_sync: DiffScrollSync, + history_show_graph: bool, history_show_author: bool, history_show_date: bool, history_show_sha: bool, + history_show_tags: bool, + history_auto_fetch_tags_on_repo_activation: bool, view_mode: GitCometViewMode, focused_mergetool_labels: Option, focused_mergetool_exit_code: Option>, @@ -920,9 +923,12 @@ impl MainPaneView { date_time_format, timezone, show_timezone, + history_show_graph, history_show_author, history_show_date, history_show_sha, + history_show_tags, + history_auto_fetch_tags_on_repo_activation, root_view.clone(), tooltip_host.clone(), last_window_size, @@ -2466,12 +2472,50 @@ impl MainPaneView { pub(in crate::view) fn history_visible_column_preferences( &self, cx: &gpui::App, - ) -> (bool, bool, bool) { + ) -> (bool, bool, bool, bool) { self.history_view .read(cx) .history_visible_column_preferences() } + pub(in crate::view) fn history_tag_preferences(&self, cx: &gpui::App) -> (bool, bool) { + self.history_view.read(cx).history_tag_preferences() + } + + pub(in crate::view) fn set_history_column_preferences( + &mut self, + show_graph: bool, + show_author: bool, + show_date: bool, + show_sha: bool, + cx: &mut gpui::Context, + ) { + self.history_view.update(cx, |view, cx| { + view.set_history_column_preferences(show_graph, show_author, show_date, show_sha, cx); + }); + cx.notify(); + } + + pub(in crate::view) fn set_history_tag_preferences( + &mut self, + show_tags: bool, + auto_fetch_tags_on_repo_activation: bool, + cx: &mut gpui::Context, + ) { + self.history_view.update(cx, |view, cx| { + view.set_history_tag_preferences(show_tags, auto_fetch_tags_on_repo_activation, cx); + }); + cx.notify(); + } + + pub(in crate::view) fn reset_history_column_widths(&mut self, cx: &mut gpui::Context) { + self.history_view.update(cx, |view, cx| { + view.reset_history_column_widths(); + cx.notify(); + }); + cx.notify(); + } + pub(in crate::view) fn open_popover_at( &mut self, kind: PopoverKind, diff --git a/crates/gitcomet-ui-gpui/src/view/panes/main/diff_cache/image_cache.rs b/crates/gitcomet-ui-gpui/src/view/panes/main/diff_cache/image_cache.rs index f5df3fa1..88e0031d 100644 --- a/crates/gitcomet-ui-gpui/src/view/panes/main/diff_cache/image_cache.rs +++ b/crates/gitcomet-ui-gpui/src/view/panes/main/diff_cache/image_cache.rs @@ -1,5 +1,5 @@ use super::*; -use crate::view::diff_utils::image_format_for_path; +use crate::view::diff_utils::{fill_svg_viewport_white, image_format_for_path}; use std::hash::Hash; use std::hash::Hasher; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -200,6 +200,7 @@ pub(in crate::view) fn render_svg_image_diff_preview( let raster_width = raster_width.max(1.0) as u32; let raster_height = raster_height.max(1.0) as u32; let mut pixmap = resvg::tiny_skia::Pixmap::new(raster_width, raster_height)?; + fill_svg_viewport_white(&mut pixmap); let transform = resvg::tiny_skia::Transform::from_scale( raster_width as f32 / svg_width, raster_height as f32 / svg_height, @@ -545,6 +546,30 @@ mod tests { .into_bytes() } + fn inset_rect_svg(width: u32, height: u32, inset_x: u32, inset_y: u32) -> Vec { + let inner_width = width.saturating_sub(inset_x.saturating_mul(2)); + let inner_height = height.saturating_sub(inset_y.saturating_mul(2)); + format!( + r##" + +"## + ) + .into_bytes() + } + + fn render_pixel_bgra(render: &gpui::RenderImage, x: usize, y: usize) -> [u8; 4] { + let size = render.size(0); + let width = size.width.0 as usize; + let offset = (y.saturating_mul(width).saturating_add(x)).saturating_mul(4); + let bytes = render.as_bytes(0).expect("render bytes"); + [ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + ] + } + fn write_test_file(dir: &Path, name: &str, bytes: &[u8]) -> std::path::PathBuf { let path = dir.join(name); std::fs::write(&path, bytes).expect("write test file"); @@ -626,6 +651,23 @@ mod tests { assert!(preview.cached_path.is_none()); } + #[test] + fn render_svg_image_diff_preview_fills_transparent_viewport_white() { + let svg = inset_rect_svg(4, 4, 1, 1); + let render = render_svg_image_diff_preview(&svg).expect("svg render image"); + let size = render.size(0); + + assert_eq!(render_pixel_bgra(&render, 0, 0), [255, 255, 255, 255]); + assert_eq!( + render_pixel_bgra( + &render, + (size.width.0 as usize) / 2, + (size.height.0 as usize) / 2, + ), + [255, 170, 0, 255] + ); + } + #[test] fn decode_file_image_diff_preview_side_upscales_small_svg_to_target_width() { let svg = solid_rect_svg(32, 16); diff --git a/crates/gitcomet-ui-gpui/src/view/panes/main/diff_search.rs b/crates/gitcomet-ui-gpui/src/view/panes/main/diff_search.rs index 83b32a24..e733f337 100644 --- a/crates/gitcomet-ui-gpui/src/view/panes/main/diff_search.rs +++ b/crates/gitcomet-ui-gpui/src/view/panes/main/diff_search.rs @@ -371,13 +371,9 @@ impl MainPaneView { if *area != DiffArea::Unstaged { return None; } - let Loadable::Ready(status) = &repo.status else { - return None; - }; - let conflict = status - .unstaged - .iter() - .find(|e| e.path == *path && e.kind == FileStatusKind::Conflicted)?; + let conflict = repo + .status_entry_for_path(DiffArea::Unstaged, path.as_path()) + .filter(|entry| entry.kind == FileStatusKind::Conflicted)?; Some((path.clone(), conflict.conflict)) } diff --git a/crates/gitcomet-ui-gpui/src/view/panes/main/preview.rs b/crates/gitcomet-ui-gpui/src/view/panes/main/preview.rs index 1dd1bb67..5d4e219f 100644 --- a/crates/gitcomet-ui-gpui/src/view/panes/main/preview.rs +++ b/crates/gitcomet-ui-gpui/src/view/panes/main/preview.rs @@ -277,13 +277,9 @@ impl MainPaneView { if *area != DiffArea::Unstaged { return false; } - let Loadable::Ready(status) = &repo.status else { - return false; - }; - let conflict_kind = status - .unstaged - .iter() - .find(|e| e.path == *path && e.kind == FileStatusKind::Conflicted) + let conflict_kind = repo + .status_entry_for_path(DiffArea::Unstaged, path.as_path()) + .filter(|entry| entry.kind == FileStatusKind::Conflicted) .and_then(|e| e.conflict); Self::conflict_resolver_strategy(conflict_kind, false).is_some() }) @@ -465,10 +461,9 @@ impl MainPaneView { } let is_untracked = *area == DiffArea::Unstaged - && matches!(&repo.status, Loadable::Ready(status) if status - .unstaged - .iter() - .any(|e| e.kind == FileStatusKind::Untracked && &e.path == path)); + && repo + .status_entry_for_path(DiffArea::Unstaged, path.as_path()) + .is_some_and(|entry| entry.kind == FileStatusKind::Untracked); if is_untracked { Some( @@ -617,10 +612,6 @@ impl MainPaneView { &self, ) -> Option { let repo = self.active_repo()?; - let status = match &repo.status { - Loadable::Ready(s) => s, - _ => return None, - }; let workdir = repo.spec.workdir.clone(); let DiffTarget::WorkingTree { path, area } = repo.diff_state.diff_target.as_ref()? else { return None; @@ -628,10 +619,9 @@ impl MainPaneView { if *area != DiffArea::Unstaged { return None; } - let is_untracked = status - .unstaged - .iter() - .any(|e| e.kind == FileStatusKind::Untracked && &e.path == path); + let is_untracked = repo + .status_entry_for_path(DiffArea::Unstaged, path.as_path()) + .is_some_and(|entry| entry.kind == FileStatusKind::Untracked); is_untracked.then(|| { if path.is_absolute() { path.clone() @@ -653,14 +643,9 @@ impl MainPaneView { if *area != DiffArea::Staged { return None; } - let status = match &repo.status { - Loadable::Ready(s) => s, - _ => return None, - }; - let is_added = status - .staged - .iter() - .any(|e| e.kind == FileStatusKind::Added && &e.path == path); + let is_added = repo + .status_entry_for_path(DiffArea::Staged, path.as_path()) + .is_some_and(|entry| entry.kind == FileStatusKind::Added); if !is_added { return None; } @@ -703,17 +688,9 @@ impl MainPaneView { match target { DiffTarget::WorkingTree { path, area } => { - let status = match &repo.status { - Loadable::Ready(s) => s, - _ => return None, - }; - let entries = match area { - DiffArea::Unstaged => status.unstaged.as_slice(), - DiffArea::Staged => status.staged.as_slice(), - }; - let is_deleted = entries - .iter() - .any(|e| e.kind == FileStatusKind::Deleted && &e.path == path); + let is_deleted = repo + .status_entry_for_path(*area, path.as_path()) + .is_some_and(|entry| entry.kind == FileStatusKind::Deleted); if !is_deleted { return None; } diff --git a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/diff_fixtures.rs b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/diff_fixtures.rs index 1c8ee7c6..0d4253d9 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/diff_fixtures.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/diff_fixtures.rs @@ -1793,14 +1793,7 @@ impl RepoTabDragFixture { Self { tab_count, tab_width_px: 120.0, - baseline: AppState { - repos, - active_repo: active, - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(repos, active), } } @@ -1848,6 +1841,24 @@ impl RepoTabDragFixture { (h.finish(), metrics) } + #[cfg(test)] + pub fn hit_test_target_repo_ids(&self) -> Vec { + let repos = &self.baseline.repos; + let steps = self.tab_count * 3; + let total_bar_width = self.tab_count as f32 * self.tab_width_px; + let mut ids = Vec::with_capacity(steps); + + for step in 0..steps { + let frac = (step as f32) / (steps.max(1) as f32); + let cursor_x = frac * total_bar_width; + let tab_ix = (cursor_x / self.tab_width_px) as usize; + let tab_ix = tab_ix.min(self.tab_count.saturating_sub(1)); + ids.push(repos[tab_ix].id); + } + + ids + } + /// Full reducer dispatch — hit-test + reorder_repo_tabs for each step. pub fn run_reorder(&self) -> (u64, RepoTabDragMetrics) { use gitcomet_state::benchmarks::dispatch_sync; diff --git a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/git_ops.rs b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/git_ops.rs index bdc8c14d..cf8e52f6 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/git_ops.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/git_ops.rs @@ -420,7 +420,7 @@ impl GitOpsFixture { fn execute(&self) -> (u64, GitOpsOutcome) { match &self.scenario { GitOpsScenario::StatusDirty { .. } | GitOpsScenario::StatusClean { .. } => { - let status = self.repo.status().expect("git_ops status benchmark"); + let status = load_split_repo_status(self.repo.as_ref(), "git_ops status benchmark"); let dirty_files = status.staged.len().saturating_add(status.unstaged.len()); ( hash_repo_status(&status), diff --git a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/real_repo.rs b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/real_repo.rs index 2f782964..85106e6a 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/real_repo.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/real_repo.rs @@ -253,10 +253,8 @@ impl RealRepoFixture { } fn run_monorepo_open_and_history(&self) -> (u64, RealRepoMetrics) { - let status = self - .repo - .status() - .expect("real_repo monorepo status benchmark"); + let status = + load_split_repo_status(self.repo.as_ref(), "real_repo monorepo status benchmark"); let branches = self .repo .list_branches() @@ -377,10 +375,8 @@ impl RealRepoFixture { } fn run_mid_merge_conflict_list_and_open(&self) -> (u64, RealRepoMetrics) { - let status = self - .repo - .status() - .expect("real_repo mid-merge status benchmark"); + let status = + load_split_repo_status(self.repo.as_ref(), "real_repo mid-merge status benchmark"); let conflict_paths = conflict_paths_from_status_or_git(&status, self.repo.spec().workdir.as_path()); let selected_path = self @@ -703,8 +699,7 @@ fn build_real_repo_state( repo.remotes_rev = 1; repo.remote_branches = Loadable::Ready(Arc::new(remote_branches)); repo.remote_branches_rev = 1; - repo.status = Loadable::Ready(Arc::new(status)); - repo.status_rev = 1; + seed_repo_status(&mut repo, status); repo.stashes = Loadable::Ready(Arc::new(Vec::new())); repo.stashes_rev = 1; repo.worktrees = Loadable::Ready(Arc::new(Vec::new())); diff --git a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/repo_history.rs b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/repo_history.rs index f445ce67..6d167d7c 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/repo_history.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/repo_history.rs @@ -1099,14 +1099,7 @@ impl RepoSwitchFixture { ); Self { - baseline: AppState { - repos: vec![repo], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(vec![repo], Some(RepoId(1))), target_repo_id: RepoId(1), } } @@ -1140,14 +1133,7 @@ impl RepoSwitchFixture { ); Self { - baseline: AppState { - repos: vec![repo1, repo2], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(vec![repo1, repo2], Some(RepoId(1))), target_repo_id: RepoId(2), } } @@ -1181,14 +1167,7 @@ impl RepoSwitchFixture { ); Self { - baseline: AppState { - repos: vec![repo1, repo2], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(vec![repo1, repo2], Some(RepoId(1))), target_repo_id: RepoId(2), } } @@ -1224,14 +1203,7 @@ impl RepoSwitchFixture { } Self { - baseline: AppState { - repos, - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(repos, Some(RepoId(1))), target_repo_id: RepoId(u64::try_from(TAB_COUNT).unwrap_or(u64::MAX)), } } @@ -1267,14 +1239,7 @@ impl RepoSwitchFixture { } Self { - baseline: AppState { - repos, - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(repos, Some(RepoId(1))), target_repo_id: RepoId(u64::try_from(REPO_COUNT).unwrap_or(u64::MAX)), } } @@ -1313,14 +1278,7 @@ impl RepoSwitchFixture { populate_loaded_diff_state(&mut repo2, "src/lib.rs", 500); Self { - baseline: AppState { - repos: vec![repo1, repo2], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(vec![repo1, repo2], Some(RepoId(1))), target_repo_id: RepoId(2), } } @@ -1359,14 +1317,7 @@ impl RepoSwitchFixture { populate_conflict_state(&mut repo2, "src/conflict_b.rs", 200); Self { - baseline: AppState { - repos: vec![repo1, repo2], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(vec![repo1, repo2], Some(RepoId(1))), target_repo_id: RepoId(2), } } @@ -1413,14 +1364,7 @@ impl RepoSwitchFixture { repo2.merge_message_rev = 1; Self { - baseline: AppState { - repos: vec![repo1, repo2], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(vec![repo1, repo2], Some(RepoId(1))), target_repo_id: RepoId(2), } } @@ -1463,7 +1407,8 @@ pub struct HistoryGraphFixture { fn repo_switch_repo_is_hydrated(repo: &RepoState) -> bool { matches!(repo.open, Loadable::Ready(())) - && matches!(repo.status, Loadable::Ready(_)) + && repo.worktree_status_entries().is_some() + && repo.staged_status_entries().is_some() && matches!(repo.log, Loadable::Ready(_)) && matches!(repo.history_state.log, Loadable::Ready(_)) && matches!(repo.branches, Loadable::Ready(_)) diff --git a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/runtime_fixtures.rs b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/runtime_fixtures.rs index 33a33666..783f2ad0 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/runtime_fixtures.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/runtime_fixtures.rs @@ -152,13 +152,10 @@ impl FsEventFixture { metrics.mutation_files = 1; // 2. Run git status. - let start = std::time::Instant::now(); - let status = self - .repo - .status() - .expect("fs_event single_file_save status"); - metrics.status_ms = start.elapsed().as_secs_f64() * 1_000.0; - metrics.status_calls = 1; + let (status, status_calls, status_ms) = + measure_split_repo_status(self.repo.as_ref(), "fs_event single_file_save"); + metrics.status_ms = status_ms; + metrics.status_calls = status_calls; let dirty = status.staged.len().saturating_add(status.unstaged.len()); metrics.dirty_files_detected = u64::try_from(dirty).unwrap_or(u64::MAX); @@ -189,13 +186,10 @@ impl FsEventFixture { metrics.mutation_files = u64::try_from(checkout_files).unwrap_or(u64::MAX); // 2. Run git status. - let start = std::time::Instant::now(); - let status = self - .repo - .status() - .expect("fs_event git_checkout_batch status"); - metrics.status_ms = start.elapsed().as_secs_f64() * 1_000.0; - metrics.status_calls = 1; + let (status, status_calls, status_ms) = + measure_split_repo_status(self.repo.as_ref(), "fs_event git_checkout_batch"); + metrics.status_ms = status_ms; + metrics.status_calls = status_calls; let dirty = status.staged.len().saturating_add(status.unstaged.len()); metrics.dirty_files_detected = u64::try_from(dirty).unwrap_or(u64::MAX); @@ -229,13 +223,10 @@ impl FsEventFixture { metrics.coalesced_saves = metrics.mutation_files; // 2. Single coalesced status call (debounce model). - let start = std::time::Instant::now(); - let status = self - .repo - .status() - .expect("fs_event rapid_saves_debounce status"); - metrics.status_ms = start.elapsed().as_secs_f64() * 1_000.0; - metrics.status_calls = 1; + let (status, status_calls, status_ms) = + measure_split_repo_status(self.repo.as_ref(), "fs_event rapid_saves_debounce"); + metrics.status_ms = status_ms; + metrics.status_calls = status_calls; let dirty = status.staged.len().saturating_add(status.unstaged.len()); metrics.dirty_files_detected = u64::try_from(dirty).unwrap_or(u64::MAX); @@ -271,13 +262,12 @@ impl FsEventFixture { metrics.mutation_files = u64::try_from(churn_files).unwrap_or(u64::MAX); // 3. Status should find 0 dirty files — the FS events were false positives. - let start = std::time::Instant::now(); - let status = self - .repo - .status() - .expect("fs_event false_positive_under_churn status"); - metrics.status_ms = start.elapsed().as_secs_f64() * 1_000.0; - metrics.status_calls = 1; + let (status, status_calls, status_ms) = measure_split_repo_status( + self.repo.as_ref(), + "fs_event false_positive_under_churn", + ); + metrics.status_ms = status_ms; + metrics.status_calls = status_calls; let dirty = status.staged.len().saturating_add(status.unstaged.len()); metrics.dirty_files_detected = u64::try_from(dirty).unwrap_or(u64::MAX); @@ -702,10 +692,10 @@ impl IdleResourceFixture { let mut status_calls = 0u64; let mut status_ms = 0.0f64; for repo in &self.repos { - let started_at = Instant::now(); - let status = repo.status().expect("idle_resource repo refresh status"); - status_ms += started_at.elapsed().as_secs_f64() * 1_000.0; - status_calls = status_calls.saturating_add(1); + let (status, repo_calls, repo_status_ms) = + measure_split_repo_status(repo.as_ref(), "idle_resource repo refresh"); + status_ms += repo_status_ms; + status_calls = status_calls.saturating_add(repo_calls); hash_repo_status(&status).hash(&mut h); } (h.finish(), status_calls, status_ms) @@ -724,12 +714,31 @@ fn idle_sample_steps(window: Duration, interval: Duration) -> usize { usize::try_from(steps.max(1)).unwrap_or(usize::MAX) } +#[cfg(target_os = "linux")] +#[cfg(any(test, feature = "benchmarks"))] +pub(crate) fn parse_first_u64_ascii_token(bytes: &[u8]) -> Option { + std::str::from_utf8(bytes) + .ok()? + .split_ascii_whitespace() + .next()? + .parse::() + .ok() +} + +#[cfg(target_os = "linux")] +#[cfg(any(test, feature = "benchmarks"))] +pub(crate) fn parse_vmrss_kib(bytes: &[u8]) -> Option { + std::str::from_utf8(bytes).ok()?.lines().find_map(|line| { + let value = line.strip_prefix("VmRSS:")?.split_whitespace().next()?; + value.parse::().ok() + }) +} + #[cfg(target_os = "linux")] #[cfg(any(test, feature = "benchmarks"))] fn current_cpu_runtime_ns() -> Option { - let schedstat = fs::read_to_string("/proc/self/schedstat").ok()?; - let runtime_ns = schedstat.split_whitespace().next()?; - runtime_ns.parse::().ok() + let schedstat = fs::read("/proc/self/schedstat").ok()?; + parse_first_u64_ascii_token(&schedstat) } #[cfg(not(target_os = "linux"))] @@ -741,11 +750,8 @@ fn current_cpu_runtime_ns() -> Option { #[cfg(target_os = "linux")] #[cfg(any(test, feature = "benchmarks"))] fn current_rss_kib() -> Option { - let status = fs::read_to_string("/proc/self/status").ok()?; - status.lines().find_map(|line| { - let value = line.strip_prefix("VmRSS:")?.split_whitespace().next()?; - value.parse::().ok() - }) + let status = fs::read("/proc/self/status").ok()?; + parse_vmrss_kib(&status) } #[cfg(not(target_os = "linux"))] diff --git a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/scroll_fixtures.rs b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/scroll_fixtures.rs index 2601bbc8..32a5816e 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/scroll_fixtures.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/scroll_fixtures.rs @@ -852,11 +852,7 @@ impl KeyboardStageUnstageToggleFixture { let commits = build_synthetic_commits(100); let mut repo = build_synthetic_repo_state(20, 40, 2, 0, 0, 0, &commits); - repo.status = Loadable::Ready(Arc::new(RepoStatus { - unstaged: entries.clone(), - staged: entries, - })); - repo.status_rev = 1; + seed_repo_status_entries(&mut repo, entries.clone(), entries); repo.open = Loadable::Ready(()); repo.diff_state.diff_target = Some(DiffTarget::WorkingTree { path: paths[0].clone(), @@ -865,14 +861,7 @@ impl KeyboardStageUnstageToggleFixture { repo.diff_state.diff_state_rev = 1; Self { - baseline: AppState { - repos: vec![repo], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(vec![repo], Some(RepoId(1))), paths, toggle_events: toggle_events.max(1), frame_budget_ns: frame_budget_ns.max(1), diff --git a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/search_fixtures.rs b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/search_fixtures.rs index 2d16f494..775e28a4 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/search_fixtures.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/search_fixtures.rs @@ -365,6 +365,17 @@ impl CommitSearchFilterFixture { } seen.len() } + + #[cfg(test)] + pub fn distinct_message_trigrams(&self) -> usize { + let mut seen = std::collections::HashSet::new(); + for summary in &self.summaries_lower { + for trigram in summary.as_bytes().windows(3) { + seen.insert(<[u8; 3]>::try_from(trigram).expect("3-byte trigram")); + } + } + seen.len() + } } /// Metrics emitted as sidecar JSON for in-diff text search benchmarks. @@ -1024,6 +1035,11 @@ impl FileFuzzyFindFixture { ) } + #[cfg(test)] + pub fn run_find_without_ordered_pair_prefilter(&self, query: &str) -> u64 { + self.run_find(query) + } + fn scan_matches(&self, query: &str) -> FileFuzzyFindRunResult { let Some(query) = AsciiCaseInsensitiveSubsequenceNeedle::new(query.trim()) else { return FileFuzzyFindRunResult { diff --git a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/status_fixtures.rs b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/status_fixtures.rs index 275f93df..d3e20b95 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/status_fixtures.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/status_fixtures.rs @@ -305,22 +305,11 @@ impl StatusSelectDiffOpenFixture { let commits = build_synthetic_commits(100); let mut repo = build_synthetic_repo_state(20, 40, 2, 0, 0, 0, &commits); - repo.status = Loadable::Ready(Arc::new(RepoStatus { - unstaged: entries, - staged: Vec::new(), - })); - repo.status_rev = 1; + seed_repo_status_entries(&mut repo, entries, Vec::new()); repo.open = Loadable::Ready(()); Self { - baseline: AppState { - repos: vec![repo], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(vec![repo], Some(RepoId(1))), diff_target: DiffTarget::WorkingTree { path: target_path, area: DiffArea::Unstaged, @@ -334,22 +323,11 @@ impl StatusSelectDiffOpenFixture { let commits = build_synthetic_commits(100); let mut repo = build_synthetic_repo_state(20, 40, 2, 0, 0, 0, &commits); - repo.status = Loadable::Ready(Arc::new(RepoStatus { - staged: entries, - unstaged: Vec::new(), - })); - repo.status_rev = 1; + seed_repo_status_entries(&mut repo, Vec::new(), entries); repo.open = Loadable::Ready(()); Self { - baseline: AppState { - repos: vec![repo], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(vec![repo], Some(RepoId(1))), diff_target: DiffTarget::WorkingTree { path: target_path, area: DiffArea::Staged, diff --git a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/support.rs b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/support.rs index bdfbd4b9..82bed05a 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/support.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/support.rs @@ -198,6 +198,64 @@ pub(crate) fn build_synthetic_repo_state( repo } +pub(crate) fn bench_app_state(repos: Vec, active_repo: Option) -> AppState { + AppState { + repos, + active_repo, + ..AppState::default() + } +} + +pub(crate) fn seed_repo_status_entries( + repo: &mut RepoState, + unstaged: Vec, + staged: Vec, +) { + repo.has_unstaged_conflicts = unstaged + .iter() + .any(|entry| entry.kind == FileStatusKind::Conflicted); + repo.worktree_status = Loadable::Ready(Arc::new(unstaged.clone())); + repo.worktree_status_rev = 1; + repo.staged_status = Loadable::Ready(Arc::new(staged.clone())); + repo.staged_status_rev = 1; + repo.status = Loadable::Ready(Arc::new(RepoStatus { unstaged, staged })); + repo.status_rev = 1; +} + +pub(crate) fn seed_repo_status(repo: &mut RepoState, status: RepoStatus) { + let RepoStatus { unstaged, staged } = status; + seed_repo_status_entries(repo, unstaged, staged); +} + +pub(crate) fn load_split_repo_status(repo: &dyn GitRepository, context: &str) -> RepoStatus { + let unstaged = repo + .worktree_status() + .unwrap_or_else(|error| panic!("{context} worktree_status failed: {error}")); + let staged = repo + .staged_status() + .unwrap_or_else(|error| panic!("{context} staged_status failed: {error}")); + RepoStatus { unstaged, staged } +} + +pub(crate) fn measure_split_repo_status( + repo: &dyn GitRepository, + context: &str, +) -> (RepoStatus, u64, f64) { + let started_at = std::time::Instant::now(); + let unstaged = repo + .worktree_status() + .unwrap_or_else(|error| panic!("{context} worktree_status failed: {error}")); + let worktree_ms = started_at.elapsed().as_secs_f64() * 1_000.0; + + let started_at = std::time::Instant::now(); + let staged = repo + .staged_status() + .unwrap_or_else(|error| panic!("{context} staged_status failed: {error}")); + let staged_ms = started_at.elapsed().as_secs_f64() * 1_000.0; + + (RepoStatus { unstaged, staged }, 2, worktree_ms + staged_ms) +} + pub(crate) fn build_repo_switch_repo_state( repo_id: RepoId, workdir: &str, @@ -227,8 +285,7 @@ pub(crate) fn build_repo_switch_repo_state( repo.log = Loadable::Ready(log_page); repo.log_rev = 1; - repo.status = Loadable::Ready(Arc::new(build_synthetic_repo_status(status_entries))); - repo.status_rev = 1; + seed_repo_status(&mut repo, build_synthetic_repo_status(status_entries)); repo.tags = Loadable::Ready(Arc::new(build_tags_targeting_commits(commits, 32))); repo.tags_rev = 1; repo.remote_tags = Loadable::Ready(Arc::new(Vec::new())); diff --git a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/tests.rs b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/tests.rs index 337214ac..bd20cf5e 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/tests.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/tests.rs @@ -86,7 +86,7 @@ fn git_ops_status_fixture_reports_dirty_status_metrics() { assert_eq!(hash_without_trace, hash_with_trace); assert_eq!(metrics.tracked_files, 32); assert_eq!(metrics.dirty_files, 8); - assert_eq!(metrics.status_calls, 1); + assert_eq!(metrics.status_calls, 2); assert_eq!(metrics.log_walk_calls, 0); assert_eq!(metrics.diff_calls, 0); assert_eq!(metrics.blame_calls, 0); @@ -2171,15 +2171,10 @@ fn repo_tab_drag_hit_test_covers_all_tabs() { #[test] fn repo_tab_drag_hit_test_precomputes_expected_sweep() { let fixture = RepoTabDragFixture::new(4); - assert_eq!(fixture.hit_test_steps.len(), 12); - assert_eq!( - fixture.hit_test_steps.first().unwrap().target.repo_id, - RepoId(1) - ); - assert_eq!( - fixture.hit_test_steps.last().unwrap().target.repo_id, - RepoId(4) - ); + let target_ids = fixture.hit_test_target_repo_ids(); + assert_eq!(target_ids.len(), 12); + assert_eq!(target_ids.first().copied(), Some(RepoId(1))); + assert_eq!(target_ids.last().copied(), Some(RepoId(4))); } #[test] @@ -2711,7 +2706,7 @@ fn git_ops_status_clean_fixture_reports_zero_dirty_metrics() { assert_eq!(hash_without_trace, hash_with_trace); assert_eq!(metrics.tracked_files, 32); assert_eq!(metrics.dirty_files, 0); - assert_eq!(metrics.status_calls, 1); + assert_eq!(metrics.status_calls, 2); assert_eq!(metrics.log_walk_calls, 0); assert_eq!(metrics.ref_enumerate_calls, 0); assert!(metrics.status_ms > 0.0); @@ -3086,7 +3081,7 @@ fn fs_event_single_file_save_detects_one_dirty_file() { assert_ne!(hash, 0); assert_eq!(metrics.mutation_files, 1); assert_eq!(metrics.dirty_files_detected, 1); - assert_eq!(metrics.status_calls, 1); + assert_eq!(metrics.status_calls, 2); assert!(metrics.tracked_files >= 50); assert!(metrics.status_ms > 0.0); } @@ -3098,7 +3093,7 @@ fn fs_event_git_checkout_batch_detects_all_dirty_files() { assert_ne!(hash, 0); assert_eq!(metrics.mutation_files, 30); assert_eq!(metrics.dirty_files_detected, 30); - assert_eq!(metrics.status_calls, 1); + assert_eq!(metrics.status_calls, 2); assert!(metrics.tracked_files >= 100); } @@ -3109,7 +3104,7 @@ fn fs_event_rapid_saves_debounce_coalesces_into_single_status() { assert_ne!(hash, 0); assert_eq!(metrics.coalesced_saves, 20); assert_eq!(metrics.dirty_files_detected, 20); - assert_eq!(metrics.status_calls, 1); + assert_eq!(metrics.status_calls, 2); assert!(metrics.tracked_files >= 80); } @@ -3121,7 +3116,7 @@ fn fs_event_false_positive_under_churn_finds_zero_dirty() { assert_eq!(metrics.mutation_files, 20); assert_eq!(metrics.dirty_files_detected, 0); assert_eq!(metrics.false_positives, 20); - assert_eq!(metrics.status_calls, 1); + assert_eq!(metrics.status_calls, 2); } #[test] @@ -3180,7 +3175,7 @@ fn idle_background_refresh_short_window_reports_status_calls() { assert_eq!(metrics.open_repos, 3); assert_eq!(metrics.refresh_cycles, 4); assert_eq!(metrics.repos_refreshed, 12); - assert_eq!(metrics.status_calls, 12); + assert_eq!(metrics.status_calls, 24); assert!(metrics.status_ms > 0.0); assert!(metrics.avg_refresh_cycle_ms > 0.0); assert!(metrics.max_refresh_cycle_ms >= metrics.avg_refresh_cycle_ms); @@ -3205,7 +3200,7 @@ fn idle_wake_resume_reports_single_refresh_burst() { assert_eq!(metrics.open_repos, 2); assert_eq!(metrics.refresh_cycles, 1); assert_eq!(metrics.repos_refreshed, 2); - assert_eq!(metrics.status_calls, 2); + assert_eq!(metrics.status_calls, 4); assert!(metrics.status_ms > 0.0); assert!(metrics.wake_resume_ms > 0.0); } @@ -3231,11 +3226,13 @@ fn idle_cpu_usage_hash_is_deterministic_for_fixed_config() { #[test] fn idle_linux_proc_parsers_extract_runtime_and_rss() { assert_eq!( - parse_first_u64_ascii_token(b"123456789 456 789\n"), + runtime_fixtures::parse_first_u64_ascii_token(b"123456789 456 789\n"), Some(123_456_789) ); assert_eq!( - parse_vmrss_kib(b"Name:\ttest\nState:\tR\nVmRSS:\t 24696 kB\nThreads:\t1\n"), + runtime_fixtures::parse_vmrss_kib( + b"Name:\ttest\nState:\tR\nVmRSS:\t 24696 kB\nThreads:\t1\n", + ), Some(24_696) ); } diff --git a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/text_fixtures.rs b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/text_fixtures.rs index 5adf9d58..37e7f662 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/text_fixtures.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/benchmarks/text_fixtures.rs @@ -38,22 +38,11 @@ impl StagingFixture { let commits = build_synthetic_commits(100); let mut repo = build_synthetic_repo_state(20, 40, 2, 0, 0, 0, &commits); - repo.status = Loadable::Ready(Arc::new(RepoStatus { - staged: Vec::new(), - unstaged: entries, - })); - repo.status_rev = 1; + seed_repo_status_entries(&mut repo, entries, Vec::new()); repo.open = Loadable::Ready(()); Self { - baseline: AppState { - repos: vec![repo], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(vec![repo], Some(RepoId(1))), paths, scenario: StagingScenario::StageAll, } @@ -70,22 +59,11 @@ impl StagingFixture { let commits = build_synthetic_commits(100); let mut repo = build_synthetic_repo_state(20, 40, 2, 0, 0, 0, &commits); - repo.status = Loadable::Ready(Arc::new(RepoStatus { - staged: entries, - unstaged: Vec::new(), - })); - repo.status_rev = 1; + seed_repo_status_entries(&mut repo, Vec::new(), entries); repo.open = Loadable::Ready(()); Self { - baseline: AppState { - repos: vec![repo], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(vec![repo], Some(RepoId(1))), paths, scenario: StagingScenario::UnstageAll, } @@ -106,19 +84,11 @@ impl StagingFixture { let commits = build_synthetic_commits(100); let mut repo = build_synthetic_repo_state(20, 40, 2, 0, 0, 0, &commits); - repo.status = Loadable::Ready(Arc::new(RepoStatus { unstaged, staged })); - repo.status_rev = 1; + seed_repo_status_entries(&mut repo, unstaged, staged); repo.open = Loadable::Ready(()); Self { - baseline: AppState { - repos: vec![repo], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }, + baseline: bench_app_state(vec![repo], Some(RepoId(1))), paths, scenario: StagingScenario::Interleaved, } @@ -418,14 +388,7 @@ fn build_undo_redo_baseline(region_count: usize) -> (AppState, RepoPath) { repo.conflict_state.conflict_rev = 1; repo.open = Loadable::Ready(()); - let baseline = AppState { - repos: vec![repo], - active_repo: Some(RepoId(1)), - clone: None, - notifications: Vec::new(), - banner_error: None, - auth_prompt: None, - }; + let baseline = bench_app_state(vec![repo], Some(RepoId(1))); (baseline, conflict_path) } diff --git a/crates/gitcomet-ui-gpui/src/view/rows/history.rs b/crates/gitcomet-ui-gpui/src/view/rows/history.rs index c9603a45..7524baee 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/history.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/history.rs @@ -1556,7 +1556,7 @@ impl HistoryView { let col_author = this.history_col_author; let col_date = this.history_col_date; let col_sha = this.history_col_sha; - let (show_author, show_date, show_sha) = this.history_visible_columns(); + let (show_graph, show_author, show_date, show_sha) = this.history_visible_columns(); let page = match &repo.log { Loadable::Ready(page) => Some(page), @@ -1586,6 +1586,7 @@ impl HistoryView { col_author, col_date, col_sha, + show_graph, show_author, show_date, show_sha, @@ -1629,6 +1630,7 @@ impl HistoryView { col_author, col_date, col_sha, + show_graph, show_author, show_date, show_sha, @@ -1706,6 +1708,7 @@ fn history_table_row( col_author: Pixels, col_date: Pixels, col_sha: Pixels, + show_graph: bool, show_author: bool, show_date: bool, show_sha: bool, @@ -1745,6 +1748,7 @@ fn history_table_row( col_author, col_date, col_sha, + show_graph, show_author, show_date, show_sha, @@ -1846,6 +1850,7 @@ fn working_tree_summary_history_row( col_author: Pixels, col_date: Pixels, col_sha: Pixels, + show_graph: bool, show_author: bool, show_date: bool, show_sha: bool, @@ -1954,15 +1959,17 @@ fn working_tree_summary_history_row( .whitespace_nowrap() .child(div()), ) - .child( - div() - .w(col_graph) - .h_full() - .flex() - .justify_center() - .overflow_hidden() - .child(circle), - ) + .when(show_graph, |row| { + row.child( + div() + .w(col_graph) + .h_full() + .flex() + .justify_center() + .overflow_hidden() + .child(circle), + ) + }) .child({ let mut summary = div() .flex_1() diff --git a/crates/gitcomet-ui-gpui/src/view/rows/history_canvas.rs b/crates/gitcomet-ui-gpui/src/view/rows/history_canvas.rs index 8209f14c..70f3722c 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/history_canvas.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/history_canvas.rs @@ -170,6 +170,7 @@ pub(super) fn history_commit_row_canvas( col_author: Pixels, col_date: Pixels, col_sha: Pixels, + show_graph: bool, show_author: bool, show_date: bool, show_sha: bool, @@ -235,11 +236,14 @@ pub(super) fn history_commit_row_canvas( size(col_branch.max(px(0.0)), bounds.size.height), ); x += col_branch; - let graph_bounds = Bounds::new( - point(x, bounds.top()), - size(col_graph.max(px(0.0)), bounds.size.height), - ); - x += col_graph; + let graph_w = if show_graph { + col_graph.max(px(0.0)) + } else { + px(0.0) + }; + let graph_bounds = + Bounds::new(point(x, bounds.top()), size(graph_w, bounds.size.height)); + x += graph_w; let mut right_x = inner.right(); let sha_bounds = if show_sha { @@ -285,23 +289,25 @@ pub(super) fn history_commit_row_canvas( size((summary_right - x).max(px(0.0)), bounds.size.height), ); - window.with_content_mask( - Some(ContentMask { - bounds: graph_bounds, - }), - |window| { - window.paint_layer(graph_bounds, |window| { - super::history_graph_paint::paint_history_graph( - theme, - graph_row, - connect_from_top_col, - is_stash_node, - graph_bounds, - window, - ); - }); - }, - ); + if show_graph { + window.with_content_mask( + Some(ContentMask { + bounds: graph_bounds, + }), + |window| { + window.paint_layer(graph_bounds, |window| { + super::history_graph_paint::paint_history_graph( + theme, + graph_row, + connect_from_top_col, + is_stash_node, + graph_bounds, + window, + ); + }); + }, + ); + } let chip_height = px(HISTORY_TAG_CHIP_HEIGHT_PX); let chip_pad_x = px(HISTORY_TAG_CHIP_PADDING_X_PX); diff --git a/crates/gitcomet-ui-gpui/src/view/rows/sidebar.rs b/crates/gitcomet-ui-gpui/src/view/rows/sidebar.rs index 3d65a877..5c437fe5 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/sidebar.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/sidebar.rs @@ -2717,11 +2717,22 @@ mod tests { #[gpui::test] fn branch_reveal_routes_through_main_pane_and_selects_commit(cx: &mut gpui::TestAppContext) { + let _visual_guard = crate::test_support::lock_visual_test(); let (store, events) = AppStore::new(Arc::new(BlockingBackend)); let store_for_assert = store.clone(); let (view, cx) = cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); + let sync_view_from_store = |cx: &mut gpui::VisualTestContext| { + cx.update(|window, app| { + view.update(app, |this, cx| { + crate::view::test_support::sync_store_snapshot(this, cx) + }); + window.refresh(); + let _ = window.draw(app); + }); + }; + let repo_id = RepoId(1); let target = commit_id("main-tip"); cx.update(|window, app| { @@ -2733,6 +2744,7 @@ mod tests { snapshot.active_repo == Some(repo_id) && snapshot.repos.iter().any(|repo| repo.id == repo_id) }); + sync_view_from_store(cx); store_for_assert.dispatch(Msg::Internal(InternalMsg::HeadBranchLoaded { repo_id, @@ -2773,6 +2785,7 @@ mod tests { && matches!(repo.log, Loadable::Ready(_)) && repo.diff_state.diff_target.is_some() }); + sync_view_from_store(cx); wait_until(cx, "history view active repo", |cx| { sync_view_for_tests(cx, &view); diff --git a/crates/gitcomet-ui-gpui/src/view/rows/status.rs b/crates/gitcomet-ui-gpui/src/view/rows/status.rs index 45e58cf1..7d154733 100644 --- a/crates/gitcomet-ui-gpui/src/view/rows/status.rs +++ b/crates/gitcomet-ui-gpui/src/view/rows/status.rs @@ -256,31 +256,9 @@ pub(super) fn apply_status_multi_selection_click( } } -fn status_entries_for_section(status: &RepoStatus, section: StatusSection) -> Vec<&FileStatus> { - match section { - StatusSection::CombinedUnstaged => status.unstaged.iter().collect(), - StatusSection::Untracked => status - .unstaged - .iter() - .filter(|entry| entry.kind == FileStatusKind::Untracked) - .collect(), - StatusSection::Unstaged => status - .unstaged - .iter() - .filter(|entry| entry.kind != FileStatusKind::Untracked) - .collect(), - StatusSection::Staged => status.staged.iter().collect(), - } -} - -fn status_paths_for_section( - status: &RepoStatus, - section: StatusSection, -) -> Vec { - status_entries_for_section(status, section) - .into_iter() - .map(|entry| entry.path.clone()) - .collect() +fn status_paths_for_section(repo: &RepoState, section: StatusSection) -> Vec { + StatusSectionEntries::from_repo(repo, section) + .map_or_else(Vec::new, StatusSectionEntries::path_vec) } impl DetailsPaneView { @@ -342,7 +320,7 @@ impl DetailsPaneView { let status_rev = self .active_repo() .filter(|repo| repo.id == repo_id) - .map(|repo| repo.status_rev); + .map(|repo| status_section_rev(repo, section)); let sel = self.status_multi_selection_for_repo_mut(repo_id); apply_status_multi_selection_click( sel, @@ -402,16 +380,15 @@ fn render_status_rows_for_section( let Some(repo) = this.active_repo() else { return Vec::new(); }; - let Loadable::Ready(status) = &repo.status else { + let Some(entries) = StatusSectionEntries::from_repo(repo, section) else { return Vec::new(); }; - let entries = status_entries_for_section(status, section); let selected = repo.diff_state.diff_target.as_ref(); let selected_paths = this.status_selected_paths_for_area(repo.id, section.diff_area()); let multi_select_active = !selected_paths.is_empty(); let theme = this.theme; range - .filter_map(|ix| entries.get(ix).copied().map(|entry| (ix, entry))) + .filter_map(|ix| entries.get(ix).map(|entry| (ix, entry))) .map(|(ix, entry)| { let path_display = this.cached_path_display(&entry.path); let is_selected = if multi_select_active { @@ -668,10 +645,7 @@ fn status_row( let entries = if modifiers.shift { this.active_repo() .filter(|r| r.id == repo_id) - .and_then(|repo| match &repo.status { - Loadable::Ready(status) => Some(status_paths_for_section(status, section)), - _ => None, - }) + .map(|repo| status_paths_for_section(repo, section)) } else { None }; diff --git a/crates/gitcomet-ui-gpui/src/view/settings_window.rs b/crates/gitcomet-ui-gpui/src/view/settings_window.rs index 749647a2..646277fd 100644 --- a/crates/gitcomet-ui-gpui/src/view/settings_window.rs +++ b/crates/gitcomet-ui-gpui/src/view/settings_window.rs @@ -1,6 +1,10 @@ use super::*; -use gitcomet_core::process::configure_background_command; +use gitcomet_core::process::{ + GitExecutablePreference, GitRuntimeState, install_git_executable_path, refresh_git_runtime, +}; +use gitcomet_state::model::GitLogTagFetchMode; use gpui::{Stateful, TitlebarOptions, WindowBounds, WindowDecorations, WindowOptions}; +use std::path::PathBuf; use std::sync::Arc; const SETTINGS_WINDOW_MIN_WIDTH_PX: f32 = 620.0; @@ -66,6 +70,8 @@ enum SettingsSection { Timezone, ChangeTracking, Diff, + GitLogColumns, + GitLogTagFetch, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -74,6 +80,21 @@ enum SettingsView { OpenSourceLicenses, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum GitExecutableMode { + SystemPath, + Custom, +} + +impl GitExecutableMode { + fn from_preference(preference: &GitExecutablePreference) -> Self { + match preference { + GitExecutablePreference::SystemPath => Self::SystemPath, + GitExecutablePreference::Custom(_) => Self::Custom, + } + } +} + #[derive(Clone, Debug)] struct SettingsRuntimeInfo { git: GitRuntimeInfo, @@ -83,6 +104,7 @@ struct SettingsRuntimeInfo { #[derive(Clone, Debug)] struct GitRuntimeInfo { + runtime: GitRuntimeState, version_display: SharedString, compatibility: GitCompatibility, detail: Option, @@ -93,6 +115,7 @@ enum GitCompatibility { Supported, TooOld, Unknown, + Unavailable, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -122,12 +145,22 @@ pub(crate) struct SettingsWindowView { show_timezone: bool, change_tracking_view: ChangeTrackingView, diff_scroll_sync: DiffScrollSync, + history_show_graph: bool, + history_show_author: bool, + history_show_date: bool, + history_show_sha: bool, + history_show_tags: bool, + history_tag_fetch_mode: GitLogTagFetchMode, current_view: SettingsView, open_source_licenses_scroll: UniformListScrollHandle, runtime_info: SettingsRuntimeInfo, + git_executable_mode: GitExecutableMode, + git_custom_path_draft: String, + git_executable_input: Entity, expanded_section: Option, hover_resize_edge: Option, title_drag_state: chrome::TitleBarDragState, + _git_executable_input_subscription: gpui::Subscription, _appearance_subscription: gpui::Subscription, } @@ -311,6 +344,51 @@ fn settings_theme_modes() -> Vec { modes } +fn history_columns_settings_label( + show_graph: bool, + show_author: bool, + show_date: bool, + show_sha: bool, +) -> SharedString { + let mut columns = Vec::new(); + if show_graph { + columns.push("Graph"); + } + if show_author { + columns.push("Author"); + } + if show_date { + columns.push("Commit date"); + } + if show_sha { + columns.push("SHA"); + } + + if columns.is_empty() { + "None".into() + } else { + columns.join(", ").into() + } +} + +fn git_log_tag_fetch_mode_label(mode: GitLogTagFetchMode) -> &'static str { + match mode { + GitLogTagFetchMode::OnRepositoryActivation => "On repository activation", + GitLogTagFetchMode::Disabled => "Disabled", + } +} + +fn applied_git_executable_path(runtime: &GitRuntimeState) -> Option { + match &runtime.preference { + GitExecutablePreference::SystemPath => None, + GitExecutablePreference::Custom(path) => Some(path.clone()), + } +} + +fn git_executable_scope_note() -> &'static str { + "Applies only to the main GitComet browser window. Git-invoked command modes keep using git from System PATH." +} + impl SettingsWindowView { fn new(window: &mut Window, cx: &mut gpui::Context) -> Self { window.set_window_title(SETTINGS_WINDOW_TITLE); @@ -344,7 +422,22 @@ impl SettingsWindowView { .as_deref() .and_then(DiffScrollSync::from_key) .unwrap_or_default(); + let history_show_graph = ui_session.history_show_graph.unwrap_or(true); + let history_show_author = ui_session.history_show_author.unwrap_or(true); + let history_show_date = ui_session.history_show_date.unwrap_or(true); + let history_show_sha = ui_session.history_show_sha.unwrap_or(false); + let history_show_tags = ui_session.history_show_tags.unwrap_or(true); + let history_tag_fetch_mode = ui_session.history_tag_fetch_mode.unwrap_or_default(); let theme = theme_mode.resolve_theme(window.appearance()); + let runtime_info = SettingsRuntimeInfo::detect(); + let git_executable_mode = + GitExecutableMode::from_preference(&runtime_info.git.runtime.preference); + let git_custom_path_draft = match &runtime_info.git.runtime.preference { + GitExecutablePreference::Custom(path) if !path.as_os_str().is_empty() => { + path.display().to_string() + } + _ => String::new(), + }; let appearance_subscription = { let view = cx.weak_entity(); @@ -365,6 +458,35 @@ impl SettingsWindowView { }) }; + let git_executable_input = cx.new(|cx| { + components::TextInput::new( + components::TextInputOptions { + placeholder: "/path/to/git".into(), + multiline: false, + read_only: false, + chromeless: false, + soft_wrap: false, + }, + window, + cx, + ) + }); + git_executable_input.update(cx, |input, cx| { + input.set_text(git_custom_path_draft.clone(), cx); + }); + let git_executable_input_subscription = + cx.observe(&git_executable_input, |this, input, cx| { + let enter_pressed = input.update(cx, |input, _| input.take_enter_pressed()); + let next = input.read(cx).text().to_string(); + if this.git_custom_path_draft != next { + this.git_custom_path_draft = next; + cx.notify(); + } + if enter_pressed && this.git_executable_mode == GitExecutableMode::Custom { + this.apply_git_executable_settings(cx); + } + }); + Self { theme_mode, theme, @@ -386,12 +508,22 @@ impl SettingsWindowView { show_timezone, change_tracking_view, diff_scroll_sync, + history_show_graph, + history_show_author, + history_show_date, + history_show_sha, + history_show_tags, + history_tag_fetch_mode, current_view: SettingsView::Root, open_source_licenses_scroll: UniformListScrollHandle::default(), - runtime_info: SettingsRuntimeInfo::detect(), + runtime_info, + git_executable_mode, + git_custom_path_draft, + git_executable_input, expanded_section: None, hover_resize_edge: None, title_drag_state: chrome::TitleBarDragState::default(), + _git_executable_input_subscription: git_executable_input_subscription, _appearance_subscription: appearance_subscription, } } @@ -423,9 +555,13 @@ impl SettingsWindowView { diff_scroll_sync: Some(self.diff_scroll_sync.key().to_string()), change_tracking_height: None, untracked_height: None, - history_show_author: None, - history_show_date: None, - history_show_sha: None, + history_show_graph: Some(self.history_show_graph), + history_show_author: Some(self.history_show_author), + history_show_date: Some(self.history_show_date), + history_show_sha: Some(self.history_show_sha), + history_show_tags: Some(self.history_show_tags), + history_tag_fetch_mode: Some(self.history_tag_fetch_mode), + git_executable_path: Some(applied_git_executable_path(&self.runtime_info.git.runtime)), }; cx.background_spawn(async move { @@ -476,6 +612,58 @@ impl SettingsWindowView { .detach(); } + fn selected_git_executable_path(&self) -> Option { + match self.git_executable_mode { + GitExecutableMode::SystemPath => None, + GitExecutableMode::Custom => { + let trimmed = self.git_custom_path_draft.trim(); + Some(if trimmed.is_empty() { + std::path::PathBuf::new() + } else { + std::path::PathBuf::from(trimmed) + }) + } + } + } + + fn sync_git_runtime_state(&mut self, runtime: GitRuntimeState, cx: &mut gpui::Context) { + self.git_executable_mode = GitExecutableMode::from_preference(&runtime.preference); + if let GitExecutablePreference::Custom(path) = &runtime.preference { + let next_draft = if path.as_os_str().is_empty() { + String::new() + } else { + path.display().to_string() + }; + if self.git_custom_path_draft != next_draft { + self.git_custom_path_draft = next_draft.clone(); + self.git_executable_input + .update(cx, |input, cx| input.set_text(next_draft, cx)); + } + } + + self.runtime_info = SettingsRuntimeInfo::from_runtime(runtime.clone()); + self.persist_preferences(cx); + self.update_main_windows(cx, move |view, _window, _cx| { + view.store + .dispatch(Msg::SetGitRuntimeState(runtime.clone())); + }); + cx.notify(); + } + + fn apply_git_executable_settings(&mut self, cx: &mut gpui::Context) { + let runtime = install_git_executable_path(self.selected_git_executable_path()); + self.sync_git_runtime_state(runtime, cx); + } + + fn set_git_executable_mode(&mut self, mode: GitExecutableMode, cx: &mut gpui::Context) { + if self.git_executable_mode == mode { + return; + } + + self.git_executable_mode = mode; + self.apply_git_executable_settings(cx); + } + fn font_option_detail(&self, family: &str) -> Option { match family { crate::font_preferences::UI_SYSTEM_FONT_FAMILY => { @@ -663,6 +851,69 @@ impl SettingsWindowView { cx.notify(); } + fn set_history_column_preferences( + &mut self, + show_graph: bool, + show_author: bool, + show_date: bool, + show_sha: bool, + cx: &mut gpui::Context, + ) { + if self.history_show_graph == show_graph + && self.history_show_author == show_author + && self.history_show_date == show_date + && self.history_show_sha == show_sha + { + return; + } + + self.history_show_graph = show_graph; + self.history_show_author = show_author; + self.history_show_date = show_date; + self.history_show_sha = show_sha; + self.persist_preferences(cx); + self.update_main_windows(cx, move |view, _window, cx| { + view.set_history_column_preferences(show_graph, show_author, show_date, show_sha, cx); + }); + cx.notify(); + } + + fn set_history_show_tags(&mut self, enabled: bool, cx: &mut gpui::Context) { + if self.history_show_tags == enabled { + return; + } + + self.history_show_tags = enabled; + if !enabled && self.expanded_section == Some(SettingsSection::GitLogTagFetch) { + self.expanded_section = None; + } + let tag_fetch_mode = self.history_tag_fetch_mode; + self.persist_preferences(cx); + self.update_main_windows(cx, move |view, _window, cx| { + view.set_history_tag_preferences(enabled, tag_fetch_mode, cx); + }); + cx.notify(); + } + + fn set_history_tag_fetch_mode( + &mut self, + mode: GitLogTagFetchMode, + cx: &mut gpui::Context, + ) { + if self.history_tag_fetch_mode == mode { + return; + } + + self.history_tag_fetch_mode = mode; + self.expanded_section = None; + let show_tags = self.history_show_tags; + self.persist_preferences(cx); + self.update_main_windows(cx, move |view, _window, cx| { + view.set_history_tag_preferences(show_tags, mode, cx); + }); + cx.notify(); + } + fn option_row( &self, id: impl Into, @@ -1497,6 +1748,9 @@ impl Render for SettingsWindowView { ) }); + self.git_executable_input + .update(cx, |input, cx| input.set_theme(theme, cx)); + let content = match self.current_view { SettingsView::Root => { let theme_row = self @@ -1605,6 +1859,48 @@ impl Render for SettingsWindowView { this.toggle_section(SettingsSection::Diff, cx); })); + let history_columns_row = self + .summary_row( + "settings_window_git_log_columns", + "History columns", + history_columns_settings_label( + self.history_show_graph, + self.history_show_author, + self.history_show_date, + self.history_show_sha, + ), + self.expanded_section == Some(SettingsSection::GitLogColumns), + theme, + ) + .on_click(cx.listener(|this, _e: &ClickEvent, _window, cx| { + this.toggle_section(SettingsSection::GitLogColumns, cx); + })); + + let show_history_tags_row = self + .toggle_row( + "settings_window_git_log_show_tags", + "Show tags in history view", + self.history_show_tags, + theme, + ) + .on_click(cx.listener(|this, _e: &ClickEvent, _window, cx| { + this.set_history_show_tags(!this.history_show_tags, cx); + })); + + let auto_fetch_tags_row = self + .summary_row( + "settings_window_git_log_tag_fetch_mode", + "Automatically fetch tags", + git_log_tag_fetch_mode_label(self.history_tag_fetch_mode).into(), + self.expanded_section == Some(SettingsSection::GitLogTagFetch), + theme, + ) + .on_click(cx.listener(|this, _e: &ClickEvent, _window, cx| { + if this.history_show_tags { + this.toggle_section(SettingsSection::GitLogTagFetch, cx); + } + })); + let mut general_card = self .card("settings_window_general", "General", theme) .child(theme_row); @@ -1871,6 +2167,163 @@ impl Render for SettingsWindowView { )); } + let mut git_log_card = self + .card("settings_window_git_log_card", "Git log", theme) + .child(history_columns_row); + + if self.expanded_section == Some(SettingsSection::GitLogColumns) { + git_log_card = git_log_card + .child( + self.toggle_row( + "settings_window_git_log_column_graph", + "Graph", + self.history_show_graph, + theme, + ) + .on_click(cx.listener( + |this, _e: &ClickEvent, _window, cx| { + this.set_history_column_preferences( + !this.history_show_graph, + this.history_show_author, + this.history_show_date, + this.history_show_sha, + cx, + ); + }, + )), + ) + .child( + self.toggle_row( + "settings_window_git_log_column_author", + "Author", + self.history_show_author, + theme, + ) + .on_click(cx.listener( + |this, _e: &ClickEvent, _window, cx| { + this.set_history_column_preferences( + this.history_show_graph, + !this.history_show_author, + this.history_show_date, + this.history_show_sha, + cx, + ); + }, + )), + ) + .child( + self.toggle_row( + "settings_window_git_log_column_date", + "Commit date", + self.history_show_date, + theme, + ) + .on_click(cx.listener( + |this, _e: &ClickEvent, _window, cx| { + this.set_history_column_preferences( + this.history_show_graph, + this.history_show_author, + !this.history_show_date, + this.history_show_sha, + cx, + ); + }, + )), + ) + .child( + self.toggle_row( + "settings_window_git_log_column_sha", + "SHA", + self.history_show_sha, + theme, + ) + .on_click(cx.listener( + |this, _e: &ClickEvent, _window, cx| { + this.set_history_column_preferences( + this.history_show_graph, + this.history_show_author, + this.history_show_date, + !this.history_show_sha, + cx, + ); + }, + )), + ) + .child( + div() + .px_2() + .pb_1() + .text_xs() + .text_color(theme.colors.text_muted) + .child("Columns may auto-hide in narrow windows."), + ) + .child( + self.link_row( + "settings_window_git_log_reset_widths", + "Reset column widths", + "Reset".into(), + theme, + ) + .on_click(cx.listener( + |this, _e: &ClickEvent, _window, cx| { + this.update_main_windows(cx, |view, _window, cx| { + view.reset_history_column_widths(cx); + }); + cx.notify(); + }, + )), + ); + } + + git_log_card = git_log_card.child(show_history_tags_row); + if self.history_show_tags { + git_log_card = git_log_card.child(auto_fetch_tags_row); + + if self.expanded_section == Some(SettingsSection::GitLogTagFetch) { + git_log_card = git_log_card + .child( + self.option_row( + "settings_window_git_log_tag_fetch_mode_activation", + "On repository activation", + Some( + "Fetch local tags when a repository becomes active.".into(), + ), + self.history_tag_fetch_mode + == GitLogTagFetchMode::OnRepositoryActivation, + theme, + ) + .on_click(cx.listener( + |this, _e: &ClickEvent, _window, cx| { + this.set_history_tag_fetch_mode( + GitLogTagFetchMode::OnRepositoryActivation, + cx, + ); + }, + )), + ) + .child( + self.option_row( + "settings_window_git_log_tag_fetch_mode_disabled", + "Disabled", + Some( + "Skip automatic tag fetching on repository activation." + .into(), + ), + self.history_tag_fetch_mode == GitLogTagFetchMode::Disabled, + theme, + ) + .on_click(cx.listener( + |this, _e: &ClickEvent, _window, cx| { + this.set_history_tag_fetch_mode( + GitLogTagFetchMode::Disabled, + cx, + ); + }, + )), + ); + } + } + let min_git_version = format!("{MIN_GIT_MAJOR}.{MIN_GIT_MINOR}"); let (git_icon_path, git_icon_color, git_status_text): ( &'static str, @@ -1892,42 +2345,184 @@ impl Render for SettingsWindowView { theme.colors.warning, "Git version unknown".into(), ), + GitCompatibility::Unavailable => ( + "icons/warning.svg", + theme.colors.danger, + "Unavailable".into(), + ), }; - let mut environment_card = self - .card("settings_window_environment", "Environment", theme) + let system_git_row = self + .option_row( + "settings_window_git_executable_system", + "System PATH", + Some( + "Use the first `git` executable available in the current PATH.".into(), + ), + self.git_executable_mode == GitExecutableMode::SystemPath, + theme, + ) + .on_click(cx.listener(|this, _e: &ClickEvent, _window, cx| { + this.set_git_executable_mode(GitExecutableMode::SystemPath, cx); + })); + + let custom_git_row = self + .option_row( + "settings_window_git_executable_custom", + "Custom executable", + Some( + "Use a specific Git binary, such as a newer standalone installation." + .into(), + ), + self.git_executable_mode == GitExecutableMode::Custom, + theme, + ) + .on_click(cx.listener(|this, _e: &ClickEvent, _window, cx| { + this.set_git_executable_mode(GitExecutableMode::Custom, cx); + })); + + let mut git_executable_card = self + .card("settings_window_git_executable", "Git executable", theme) .child( div() - .id("settings_window_git") - .w_full() + .id("settings_window_git_executable_scope_note") .px_2() - .py_1() - .flex() - .items_center() - .justify_between() - .rounded(px(theme.radii.row)) - .child(div().text_sm().child("Git")) - .child( - div() - .flex() - .items_center() - .gap_2() - .child(svg_icon(git_icon_path, git_icon_color, px(14.0))) - .child( - div() - .text_sm() - .font_family(UI_MONOSPACE_FONT_FAMILY) - .text_color(theme.colors.text_muted) - .child(self.runtime_info.git.version_display.clone()), - ) - .child( - div() - .text_xs() - .text_color(git_icon_color) - .child(git_status_text), - ), - ), + .pb_1() + .text_xs() + .text_color(theme.colors.text_muted) + .child(git_executable_scope_note()), ) + .child(system_git_row) + .child(custom_git_row); + + if self.git_executable_mode == GitExecutableMode::Custom { + let browse_button = + components::Button::new("settings_window_git_executable_browse", "Browse") + .style(components::ButtonStyle::Outlined) + .on_click(theme, cx, |_this, _e, window, cx| { + let view = cx.weak_entity(); + let rx = cx.prompt_for_paths(gpui::PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some("Select Git executable".into()), + }); + + window + .spawn(cx, async move |cx| { + let result = rx.await; + let paths = match result { + Ok(Ok(Some(paths))) => paths, + Ok(Ok(None)) => return, + Ok(Err(_)) | Err(_) => return, + }; + let Some(path) = paths.into_iter().next() else { + return; + }; + let _ = view.update(cx, |this, cx| { + let next = path.display().to_string(); + this.git_custom_path_draft = next.clone(); + this.git_executable_input + .update(cx, |input, cx| input.set_text(next, cx)); + this.apply_git_executable_settings(cx); + }); + }) + .detach(); + }); + + let use_path_button = + components::Button::new("settings_window_git_executable_apply", "Use Path") + .style(components::ButtonStyle::Filled) + .on_click(theme, cx, |this, _e, _window, cx| { + this.apply_git_executable_settings(cx); + }); + + git_executable_card = git_executable_card + .child( + div() + .px_2() + .pt_1() + .text_xs() + .text_color(theme.colors.text_muted) + .child("Custom Git executable"), + ) + .child( + div() + .px_2() + .pb_1() + .w_full() + .min_w(px(0.0)) + .flex() + .items_center() + .gap_2() + .child( + div() + .flex_1() + .min_w(px(0.0)) + .child(self.git_executable_input.clone()), + ) + .child(browse_button) + .child(use_path_button), + ) + .child( + div() + .px_2() + .pb_1() + .text_xs() + .text_color(theme.colors.text_muted) + .child( + "Press Enter after editing the path to apply it immediately.", + ), + ); + } + + git_executable_card = git_executable_card.child( + div() + .id("settings_window_git_runtime") + .w_full() + .px_2() + .py_1() + .flex() + .items_center() + .justify_between() + .rounded(px(theme.radii.row)) + .child(div().text_sm().child("Detected runtime")) + .child( + div() + .flex() + .items_center() + .gap_2() + .child(svg_icon(git_icon_path, git_icon_color, px(14.0))) + .child( + div() + .text_sm() + .font_family(UI_MONOSPACE_FONT_FAMILY) + .text_color(theme.colors.text_muted) + .child(self.runtime_info.git.version_display.clone()), + ) + .child( + div() + .text_xs() + .text_color(git_icon_color) + .child(git_status_text), + ), + ), + ); + + if let Some(detail) = self.runtime_info.git.detail.clone() { + git_executable_card = git_executable_card.child( + div() + .id("settings_window_git_runtime_detail") + .px_2() + .pb_1() + .text_xs() + .text_color(theme.colors.text_muted) + .child(detail), + ); + } + + let environment_card = self + .card("settings_window_environment", "Environment", theme) .child(self.info_row( "settings_window_build", "Build", @@ -1941,17 +2536,6 @@ impl Render for SettingsWindowView { theme, )); - if let Some(detail) = self.runtime_info.git.detail.clone() { - environment_card = environment_card.child( - div() - .px_2() - .pt_1() - .text_xs() - .text_color(theme.colors.text_muted) - .child(detail), - ); - } - let links_card = self .card("settings_window_links", "Links", theme) .child( @@ -1985,9 +2569,10 @@ impl Render for SettingsWindowView { )), ); - div() + let scroll_surface = div() .id("settings_window_scroll") - .flex_1() + .h_full() + .min_h(px(0.0)) .overflow_y_scroll() .track_scroll(&self.settings_window_scroll) .flex() @@ -1997,8 +2582,40 @@ impl Render for SettingsWindowView { .child(general_card) .child(change_tracking_card) .child(diff_card) + .child(git_log_card) + .child(git_executable_card) .child(environment_card) - .child(links_card) + .child(links_card); + + div() + .id("settings_window_root_view") + .relative() + .flex_1() + .min_h(px(0.0)) + .child( + div() + .flex_1() + .h_full() + .min_h(px(0.0)) + .pr(components::Scrollbar::visible_gutter( + self.settings_window_scroll.clone(), + components::ScrollbarAxis::Vertical, + )) + .child(scroll_surface), + ) + .child( + { + let scrollbar = components::Scrollbar::new( + "settings_window_scrollbar", + self.settings_window_scroll.clone(), + ) + .always_visible(); + #[cfg(test)] + let scrollbar = scrollbar.debug_selector("settings_window_scrollbar"); + scrollbar + } + .render(theme), + ) } SettingsView::OpenSourceLicenses => { let rows = crate::view::open_source_licenses_data::open_source_license_rows(); @@ -2226,8 +2843,12 @@ impl Render for SettingsWindowView { impl SettingsRuntimeInfo { fn detect() -> Self { + Self::from_runtime(refresh_git_runtime()) + } + + fn from_runtime(runtime: GitRuntimeState) -> Self { Self { - git: detect_git_runtime_info(), + git: git_runtime_info_from_state(runtime), app_version_display: format!("GitComet v{}", env!("CARGO_PKG_VERSION")).into(), operating_system: format!( "{} ({}, {})", @@ -2240,105 +2861,39 @@ impl SettingsRuntimeInfo { } } -fn detect_git_runtime_info() -> GitRuntimeInfo { +fn git_runtime_info_from_state(runtime: GitRuntimeState) -> GitRuntimeInfo { let compatibility_message = format!("GitComet has been tested only with Git {MIN_GIT_MAJOR}.{MIN_GIT_MINOR} or newer."); - - let mut command = std::process::Command::new("git"); - configure_background_command(&mut command); - match command.arg("--version").output() { - Ok(output) if output.status.success() => { - let version_output = if !output.stdout.is_empty() { - bytes_to_text_preserving_utf8(&output.stdout) - .trim() - .to_string() - } else { - bytes_to_text_preserving_utf8(&output.stderr) - .trim() - .to_string() - }; - - if version_output.is_empty() { - return GitRuntimeInfo { - version_display: "Unavailable".into(), - compatibility: GitCompatibility::Unknown, - detail: Some(compatibility_message.into()), - }; - } - - let compatibility = match parse_git_version(&version_output) { - Some(version) if is_supported_git_version(version) => GitCompatibility::Supported, - Some(_) => GitCompatibility::TooOld, - None => GitCompatibility::Unknown, - }; - - GitRuntimeInfo { - version_display: version_output.into(), - compatibility, - detail: match compatibility { - GitCompatibility::Supported => None, - GitCompatibility::TooOld | GitCompatibility::Unknown => { - Some(compatibility_message.into()) - } - }, - } - } - Ok(output) => { - let stderr = bytes_to_text_preserving_utf8(&output.stderr) - .trim() - .to_string(); - let display = if stderr.is_empty() { - format!("Unavailable (exit code: {})", output.status) - } else { - format!("Unavailable ({stderr})") - }; - GitRuntimeInfo { - version_display: display.into(), - compatibility: GitCompatibility::Unknown, - detail: Some(compatibility_message.into()), - } - } - Err(err) => GitRuntimeInfo { - version_display: format!("Unavailable ({err})").into(), - compatibility: GitCompatibility::Unknown, - detail: Some(compatibility_message.into()), - }, - } -} - -fn bytes_to_text_preserving_utf8(bytes: &[u8]) -> String { - use std::fmt::Write as _; - - let mut out = String::with_capacity(bytes.len()); - let mut cursor = 0usize; - while cursor < bytes.len() { - match std::str::from_utf8(&bytes[cursor..]) { - Ok(valid) => { - out.push_str(valid); - break; - } - Err(err) => { - let valid_len = err.valid_up_to(); - if valid_len > 0 { - let valid = &bytes[cursor..cursor + valid_len]; - out.push_str( - std::str::from_utf8(valid) - .expect("slice identified by valid_up_to must be valid UTF-8"), - ); - cursor += valid_len; - } - - let invalid_len = err.error_len().unwrap_or(1); - let invalid_end = cursor.saturating_add(invalid_len).min(bytes.len()); - for byte in &bytes[cursor..invalid_end] { - let _ = write!(out, "\\x{byte:02x}"); - } - cursor = invalid_end; - } + let compatibility = if !runtime.is_available() { + GitCompatibility::Unavailable + } else { + match runtime.version_output().and_then(parse_git_version) { + Some(version) if is_supported_git_version(version) => GitCompatibility::Supported, + Some(_) => GitCompatibility::TooOld, + None => GitCompatibility::Unknown, } + }; + + let version_display = runtime + .version_output() + .unwrap_or("Unavailable") + .to_string() + .into(); + + let detail = match compatibility { + GitCompatibility::Supported => None, + GitCompatibility::TooOld | GitCompatibility::Unknown => Some(compatibility_message.into()), + GitCompatibility::Unavailable => runtime + .unavailable_detail() + .map(|detail| SharedString::from(detail.to_string())), + }; + + GitRuntimeInfo { + runtime, + version_display, + compatibility, + detail, } - - out } fn parse_git_version(raw: &str) -> Option { @@ -2373,10 +2928,13 @@ mod tests { use super::*; use crate::test_support::lock_visual_test; use gitcomet_core::error::{Error, ErrorKind}; + use gitcomet_core::process::{ + GitExecutableAvailability, GitExecutablePreference, GitRuntimeState, + }; use gitcomet_core::services::{GitBackend, GitRepository, Result}; use gpui::{Modifiers, ScrollDelta, ScrollWheelEvent}; use std::ops::Deref; - use std::path::Path; + use std::path::{Path, PathBuf}; struct TestBackend; @@ -2389,10 +2947,81 @@ mod tests { } #[test] - fn bytes_to_text_preserving_utf8_escapes_invalid_bytes() { + fn git_executable_mode_tracks_runtime_preference() { + assert_eq!( + GitExecutableMode::from_preference(&GitExecutablePreference::SystemPath), + GitExecutableMode::SystemPath + ); assert_eq!( - bytes_to_text_preserving_utf8(b"ok\xff\xfeend"), - "ok\\xff\\xfeend" + GitExecutableMode::from_preference(&GitExecutablePreference::Custom(PathBuf::from( + "/opt/git/bin/git" + ),)), + GitExecutableMode::Custom + ); + } + + #[test] + fn git_runtime_info_from_state_surfaces_unavailable_detail() { + let runtime = GitRuntimeState { + preference: GitExecutablePreference::Custom(PathBuf::new()), + availability: GitExecutableAvailability::Unavailable { + detail: "Custom Git executable is not configured. Choose an executable or switch back to System PATH.".to_string(), + }, + }; + + let info = git_runtime_info_from_state(runtime.clone()); + assert_eq!(info.runtime, runtime); + assert_eq!(info.compatibility, GitCompatibility::Unavailable); + assert_eq!(info.version_display.as_ref(), "Unavailable"); + assert_eq!( + info.detail.as_ref().map(|detail| detail.as_ref()), + Some( + "Custom Git executable is not configured. Choose an executable or switch back to System PATH." + ) + ); + } + + #[test] + fn applied_git_executable_path_tracks_runtime_preference() { + assert_eq!( + applied_git_executable_path(&GitRuntimeState { + preference: GitExecutablePreference::SystemPath, + availability: GitExecutableAvailability::Available { + version_output: "git version 2.51.0".to_string(), + }, + }), + None + ); + assert_eq!( + applied_git_executable_path(&GitRuntimeState { + preference: GitExecutablePreference::Custom(PathBuf::from("/opt/git/bin/git")), + availability: GitExecutableAvailability::Available { + version_output: "git version 2.51.0".to_string(), + }, + }), + Some(PathBuf::from("/opt/git/bin/git")) + ); + assert_eq!( + applied_git_executable_path(&GitRuntimeState { + preference: GitExecutablePreference::Custom(PathBuf::new()), + availability: GitExecutableAvailability::Unavailable { + detail: "missing".to_string(), + }, + }), + Some(PathBuf::new()) + ); + } + + #[test] + fn git_executable_scope_note_mentions_browser_only_scope() { + let note = git_executable_scope_note(); + assert!( + note.contains("browser window"), + "expected browser-only scope note, got: {note}" + ); + assert!( + note.contains("System PATH"), + "expected command-mode fallback note, got: {note}" ); } @@ -2815,6 +3444,70 @@ mod tests { }); } + #[gpui::test] + fn settings_window_root_view_renders_visible_scrollbar(cx: &mut gpui::TestAppContext) { + let _visual_guard = lock_visual_test(); + let (store, events) = AppStore::new(std::sync::Arc::new(TestBackend)); + let (_main_view, cx) = + cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); + + cx.update(|window, app| { + let _ = window.draw(app); + open_settings_window(app); + }); + cx.run_until_parked(); + + let settings_window = cx.update(|_window, app| { + app.windows() + .into_iter() + .find_map(|window| window.downcast::()) + .expect("settings window should be open") + }); + + let synthetic_fonts: Arc<[String]> = (0..200) + .map(|ix| format!("Test UI Font {ix:03}")) + .collect::>() + .into(); + + cx.update(|_window, app| { + let _ = settings_window.update(app, |settings, _window, cx| { + settings.ui_font_options = synthetic_fonts.clone(); + settings.ui_font_family = synthetic_fonts[0].clone(); + settings.expanded_section = Some(SettingsSection::UiFont); + settings.settings_window_scroll = ScrollHandle::default(); + settings.ui_font_scroll = UniformListScrollHandle::default(); + cx.notify(); + }); + }); + + let mut settings_cx = gpui::VisualTestContext::from_window(*settings_window.deref(), cx); + settings_cx.run_until_parked(); + settings_cx.simulate_resize(size( + px(SETTINGS_WINDOW_DEFAULT_WIDTH_PX), + px(SETTINGS_WINDOW_MIN_HEIGHT_PX), + )); + settings_cx.run_until_parked(); + settings_cx.update(|window, app| { + let _ = window.draw(app); + }); + + let max_offset = settings_window + .update(&mut settings_cx, |settings, _window, _cx| { + settings.settings_window_scroll.max_offset().y.max(px(0.0)) + }) + .expect("settings window should remain readable"); + assert!( + max_offset > px(0.0), + "expected the root settings page to be scrollable during the test" + ); + assert!( + settings_cx + .debug_bounds("settings_window_scrollbar") + .is_some(), + "expected a visible scrollbar in the root settings view" + ); + } + #[gpui::test] fn non_macos_settings_window_renders_custom_chrome_controls(cx: &mut gpui::TestAppContext) { if cfg!(target_os = "macos") { diff --git a/crates/gitcomet-ui-gpui/src/view/splash.rs b/crates/gitcomet-ui-gpui/src/view/splash.rs index 176809c7..1392a5a2 100644 --- a/crates/gitcomet-ui-gpui/src/view/splash.rs +++ b/crates/gitcomet-ui-gpui/src/view/splash.rs @@ -63,8 +63,58 @@ impl GitCometView { !self.state.repos.is_empty() } + fn git_runtime_unavailable(&self) -> bool { + !self.state.git_runtime.is_available() + } + + fn git_runtime_unavailable_detail(&self) -> String { + self.state + .git_runtime + .unavailable_detail() + .unwrap_or("GitComet could not find a usable Git executable.") + .to_string() + } + + fn git_unavailable_status_icon(theme: AppTheme) -> AnyElement { + div() + .id("git_unavailable_status_icon") + .debug_selector(|| "git_unavailable_status_icon".to_string()) + .size(px(56.0)) + .flex() + .items_center() + .justify_center() + .child(svg_icon( + "icons/warning.svg", + theme.colors.warning, + px(36.0), + )) + .into_any_element() + } + + fn git_runtime_unavailable_detail_content(&self) -> AnyElement { + let detail = self.git_runtime_unavailable_detail(); + if let Some((summary, recovery)) = detail.split_once(". ") { + return div() + .flex() + .flex_col() + .gap(px(0.0)) + .child(format!("{summary}.")) + .child(recovery.to_string()) + .into_any_element(); + } + + div().child(detail).into_any_element() + } + + fn should_show_git_unavailable_overlay(&self) -> bool { + renders_full_chrome(self.view_mode) + && self.has_repo_tabs() + && self.git_runtime_unavailable() + } + pub(crate) fn blocks_non_repository_actions(&self) -> bool { repository_entry_interstitial_active(self.view_mode, self.has_repo_tabs()) + || matches!(self.view_mode, GitCometViewMode::Normal) && self.git_runtime_unavailable() } pub(crate) fn is_splash_screen_active(&self) -> bool { @@ -154,6 +204,7 @@ impl GitCometView { div() .id(id) + .debug_selector(move || id.to_string()) .tab_index(0) .h(px(36.0)) .px(px(16.0)) @@ -223,6 +274,172 @@ impl GitCometView { .into_any_element() } + fn git_unavailable_open_settings_button( + &self, + cx: &mut gpui::Context, + ) -> gpui::Stateful { + let primary_bg = gpui::rgba(0x5ac1feff); + let primary_hover = gpui::rgba(0x72c7ffff); + let primary_active = gpui::rgba(0x48b6eeff); + let primary_text = gpui::rgba(0x04172bff); + let settings_tooltip: SharedString = "Open settings".into(); + + Self::splash_cta_button( + "git_unavailable_open_settings", + "Open Settings", + "icons/cog.svg", + SplashCtaButtonColors { + icon: primary_text, + text: primary_text, + background: SplashInteractiveColors { + base: primary_bg, + hover: primary_hover, + active: primary_active, + }, + border: SplashInteractiveColors { + base: primary_bg, + hover: primary_hover, + active: primary_active, + }, + }, + ) + .on_click(cx.listener(|this, _e, _window, cx| { + this.open_repo_panel = false; + cx.defer(crate::view::open_settings_window); + cx.notify(); + })) + .on_hover(cx.listener({ + let settings_tooltip = settings_tooltip.clone(); + move |this, hovering: &bool, _w, cx| { + if *hovering { + this.set_tooltip_text_if_changed(Some(settings_tooltip.clone()), cx); + } else { + this.clear_tooltip_if_matches(&settings_tooltip, cx); + } + } + })) + } + + fn git_unavailable_panel_content( + &self, + theme: AppTheme, + cx: &mut gpui::Context, + ) -> AnyElement { + let detail_bg = with_alpha( + theme.colors.window_bg, + if theme.is_dark { 0.36 } else { 0.82 }, + ); + let detail_border = + with_alpha(theme.colors.border, if theme.is_dark { 0.96 } else { 0.82 }); + + div() + .id("git_unavailable_card") + .flex() + .flex_col() + .items_center() + .gap_3() + .child(Self::git_unavailable_status_icon(theme)) + .child( + div() + .text_lg() + .font_weight(FontWeight::BOLD) + .text_center() + .child("Git executable unavailable"), + ) + .child( + div() + .max_w(px(440.0)) + .text_center() + .text_sm() + .line_height(px(22.0)) + .text_color(theme.colors.text_muted) + .child( + "GitComet cannot open, refresh, or run repository actions until a Git executable is configured.", + ), + ) + .child( + div() + .id("git_unavailable_detail") + .w_full() + .max_w(px(460.0)) + .rounded(px(theme.radii.panel)) + .border_1() + .border_color(detail_border) + .bg(detail_bg) + .px_3() + .py_2() + .text_xs() + .line_height(px(18.0)) + .text_color(theme.colors.text_muted) + .child(self.git_runtime_unavailable_detail_content()), + ) + .child( + div() + .pt_1() + .child(self.git_unavailable_open_settings_button(cx)), + ) + .into_any_element() + } + + fn git_unavailable_splash(&mut self, cx: &mut gpui::Context) -> AnyElement { + let theme = self.theme; + self.interstitial_shell( + "git_unavailable_screen", + self.git_unavailable_panel_content(theme, cx), + theme, + ) + } + + fn git_unavailable_overlay(&mut self, cx: &mut gpui::Context) -> AnyElement { + let theme = self.theme; + let border_glow = with_alpha(theme.colors.border, if theme.is_dark { 0.86 } else { 0.74 }); + + div() + .id("git_unavailable_overlay") + .debug_selector(|| "git_unavailable_overlay".to_string()) + .absolute() + .top_0() + .left_0() + .size_full() + .overflow_hidden() + .bg(with_alpha( + theme.colors.window_bg, + if theme.is_dark { 0.76 } else { 0.82 }, + )) + .child(self.interstitial_backdrop()) + .child( + div() + .relative() + .size_full() + .px_3() + .py_4() + .flex() + .items_center() + .justify_center() + .child( + div() + .w_full() + .max_w(px(560.0)) + .bg(with_alpha( + theme.colors.surface_bg, + if theme.is_dark { 0.96 } else { 0.98 }, + )) + .border_1() + .border_color(border_glow) + .rounded(px(theme.radii.panel)) + .shadow(vec![gpui::BoxShadow { + color: gpui::rgba(0x00000052).into(), + offset: point(px(0.0), px(22.0)), + blur_radius: px(52.0), + spread_radius: px(0.0), + }]) + .p_4() + .child(self.git_unavailable_panel_content(theme, cx)), + ), + ) + .into_any_element() + } + pub(super) fn startup_repository_loading_screen(&mut self) -> AnyElement { let theme = self.theme; @@ -266,6 +483,10 @@ impl GitCometView { } pub(super) fn splash_screen(&mut self, cx: &mut gpui::Context) -> AnyElement { + if self.git_runtime_unavailable() { + return self.git_unavailable_splash(cx); + } + let hero_text = gpui::rgba(0xf6f7fbff); let hero_muted = gpui::rgba(0xa8b1c6ff); let hero_proof = gpui::rgba(0xffffffbd); @@ -611,14 +832,20 @@ impl GitCometView { } if renders_full_chrome(self.view_mode) { - return div() + let content = div() .flex() .flex_col() .flex_1() .min_h(px(0.0)) - .child(self.repo_tabs_bar.clone()) + .child(stable_cached_fixed_height_view( + self.repo_tabs_bar.clone(), + components::Tab::container_height(), + )) .child(self.open_repo_panel(cx)) - .child(self.action_bar.clone()) + .child(stable_cached_fixed_height_view( + self.action_bar.clone(), + ACTION_BAR_HEIGHT, + )) .child( div() .flex() @@ -671,7 +898,7 @@ impl GitCometView { .flex_1() .min_w(px(0.0)) .min_h(px(0.0)) - .child(self.main_pane.clone()), + .child(stable_cached_fill_view(self.main_pane.clone())), ) .child(self.pane_resize_handle( theme, @@ -722,6 +949,20 @@ impl GitCometView { ), ) .into_any_element(); + + if self.should_show_git_unavailable_overlay() { + return div() + .relative() + .flex() + .flex_col() + .flex_1() + .min_h(px(0.0)) + .child(content) + .child(self.git_unavailable_overlay(cx)) + .into_any_element(); + } + + return content; } div() @@ -734,7 +975,7 @@ impl GitCometView { .flex_1() .min_w(px(0.0)) .min_h(px(0.0)) - .child(self.main_pane.clone()), + .child(stable_cached_fill_view(self.main_pane.clone())), ) .into_any_element() } diff --git a/crates/gitcomet-ui-gpui/src/view/state_apply.rs b/crates/gitcomet-ui-gpui/src/view/state_apply.rs index 071e9b0c..5fbb40a4 100644 --- a/crates/gitcomet-ui-gpui/src/view/state_apply.rs +++ b/crates/gitcomet-ui-gpui/src/view/state_apply.rs @@ -6,6 +6,8 @@ impl GitCometView { next: Arc, cx: &mut gpui::Context, ) -> bool { + let git_runtime_changed = self.state.git_runtime != next.git_runtime; + let prev_git_runtime_available = self.state.git_runtime.is_available(); let prev_had_repos = !self.state.repos.is_empty(); let prev_banner_error = self.state.banner_error.clone(); let prev_auth_prompt = self.state.auth_prompt.clone(); @@ -144,6 +146,9 @@ impl GitCometView { } self.state = next; + if !prev_git_runtime_available && self.state.git_runtime.is_available() { + self.resume_after_git_runtime_recovery(); + } for msg in follow_up_msgs { self.store.dispatch(msg); } @@ -173,7 +178,9 @@ impl GitCometView { .collect(), ); - prev_banner_error != next_banner_error || prev_auth_prompt != self.state.auth_prompt + git_runtime_changed + || prev_banner_error != next_banner_error + || prev_auth_prompt != self.state.auth_prompt } } diff --git a/crates/gitcomet-ui-gpui/src/view/tests.rs b/crates/gitcomet-ui-gpui/src/view/tests.rs index b9e18bbe..40bf2257 100644 --- a/crates/gitcomet-ui-gpui/src/view/tests.rs +++ b/crates/gitcomet-ui-gpui/src/view/tests.rs @@ -4,6 +4,7 @@ use gitcomet_core::domain::{ Upstream, Worktree, }; use gitcomet_core::error::{Error, ErrorKind}; +use gitcomet_core::process::{GitExecutableAvailability, GitExecutablePreference, GitRuntimeState}; use gitcomet_core::services::{GitBackend, GitRepository, Result}; use gitcomet_state::store::AppStore; use std::path::Path; @@ -45,6 +46,24 @@ fn wait_until(description: &str, ready: impl Fn() -> bool) { } } +fn available_git_runtime_state() -> GitRuntimeState { + GitRuntimeState { + preference: GitExecutablePreference::SystemPath, + availability: GitExecutableAvailability::Available { + version_output: "git version 2.51.0".to_string(), + }, + } +} + +fn unavailable_git_runtime_state() -> GitRuntimeState { + GitRuntimeState { + preference: GitExecutablePreference::Custom(PathBuf::new()), + availability: GitExecutableAvailability::Unavailable { + detail: "Custom Git executable is not configured. Choose an executable or switch back to System PATH.".to_string(), + }, + } +} + #[test] fn toast_total_lifetime_includes_fade_in_and_out() { let ttl = Duration::from_secs(6); @@ -1627,6 +1646,7 @@ fn ease_out_cubic_is_monotonic_in_unit_interval() { #[gpui::test] fn sidebar_expand_after_collapse_does_not_reenter_root_update(cx: &mut gpui::TestAppContext) { + let _visual_guard = crate::test_support::lock_visual_test(); let (store, events) = AppStore::new(Arc::new(TestBackend)); let (view, cx) = cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); @@ -1653,8 +1673,83 @@ fn sidebar_expand_after_collapse_does_not_reenter_root_update(cx: &mut gpui::Tes }); } +#[gpui::test] +fn details_expand_after_collapse_does_not_reenter_root_update(cx: &mut gpui::TestAppContext) { + let _visual_guard = crate::test_support::lock_visual_test(); + let (store, events) = AppStore::new(Arc::new(TestBackend)); + let (view, cx) = + cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); + + cx.update(|window, app| { + let _ = window.draw(app); + view.update(app, |this, cx| this.set_details_collapsed(true, cx)); + }); + pump_for( + cx, + Duration::from_millis(PANE_COLLAPSE_ANIM_MS.saturating_add(180)), + ); + + cx.update(|window, app| { + let _ = window.draw(app); + view.update(app, |this, cx| this.set_details_collapsed(false, cx)); + }); + pump_for( + cx, + Duration::from_millis(PANE_COLLAPSE_ANIM_MS.saturating_add(180)), + ); + + cx.update(|_window, app| { + assert!(!view.read(app).details_collapsed); + }); +} + +#[test] +fn full_chrome_layout_only_caches_always_mounted_subviews() { + let splash_source = include_str!("splash.rs"); + let normalized: String = splash_source + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); + + assert!( + normalized.contains( + "stable_cached_fixed_height_view(self.repo_tabs_bar.clone(),components::Tab::container_height(" + ), + "expected repo tabs bar to stay behind the stable cache boundary" + ); + assert!( + normalized + .contains("stable_cached_fixed_height_view(self.action_bar.clone(),ACTION_BAR_HEIGHT"), + "expected action bar to stay behind the stable cache boundary" + ); + assert!( + normalized + .matches("stable_cached_fill_view(self.main_pane.clone())") + .count() + >= 2, + "expected both full-chrome main pane mount sites to stay cached" + ); + assert!( + normalized.contains("d.child(self.sidebar_pane.clone())"), + "expected the collapsible sidebar pane to mount directly" + ); + assert!( + normalized.contains(".child(self.details_pane.clone())"), + "expected the collapsible details pane to mount directly" + ); + assert!( + !normalized.contains("stable_cached_fill_view(self.sidebar_pane.clone())"), + "sidebar pane must stay outside the stable cache boundary" + ); + assert!( + !normalized.contains("stable_cached_fill_view(self.details_pane.clone())"), + "details pane must stay outside the stable cache boundary" + ); +} + #[gpui::test] fn splash_screen_renders_when_no_repositories_are_open(cx: &mut gpui::TestAppContext) { + let _visual_guard = crate::test_support::lock_visual_test(); let (store, events) = AppStore::new(Arc::new(TestBackend)); let (view, cx) = cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); @@ -1682,8 +1777,179 @@ fn splash_screen_renders_when_no_repositories_are_open(cx: &mut gpui::TestAppCon assert!(splash_active, "expected splash screen to be active"); } +#[gpui::test] +fn git_unavailable_splash_renders_open_settings_call_to_action(cx: &mut gpui::TestAppContext) { + let _visual_guard = crate::test_support::lock_visual_test(); + let (store, events) = AppStore::new(Arc::new(TestBackend)); + let (view, cx) = + cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); + + let next = Arc::new(AppState { + git_runtime: unavailable_git_runtime_state(), + ..AppState::default() + }); + + cx.update(|window, app| { + view.update(app, |this, cx| { + this.apply_state_snapshot(Arc::clone(&next), cx); + }); + let _ = window.draw(app); + }); + + cx.debug_bounds("git_unavailable_screen") + .expect("expected git unavailable splash screen"); + cx.debug_bounds("git_unavailable_status_icon") + .expect("expected git unavailable status icon"); + cx.debug_bounds("git_unavailable_open_settings") + .expect("expected open settings call to action"); + assert!( + cx.debug_bounds("splash_open_repo_action").is_none(), + "expected repository entry actions to be hidden while Git is unavailable" + ); + + cx.update(|_window, app| { + assert!(view.read(app).is_splash_screen_active()); + assert!(view.read(app).blocks_non_repository_actions()); + }); +} + +#[gpui::test] +fn git_unavailable_open_settings_button_publishes_expected_tooltip(cx: &mut gpui::TestAppContext) { + let _visual_guard = crate::test_support::lock_visual_test(); + let (store, events) = AppStore::new(Arc::new(TestBackend)); + let (view, cx) = + cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); + + let next = Arc::new(AppState { + git_runtime: unavailable_git_runtime_state(), + ..AppState::default() + }); + + cx.update(|window, app| { + view.update(app, |this, cx| { + this.apply_state_snapshot(Arc::clone(&next), cx); + }); + let _ = window.draw(app); + }); + + let button_center = cx + .debug_bounds("git_unavailable_open_settings") + .expect("expected open settings call to action") + .center(); + cx.simulate_mouse_move(button_center, None, gpui::Modifiers::default()); + cx.run_until_parked(); + + cx.update(|_window, app| { + assert_eq!( + test_support::tooltip_text(view.read(app), app).map(|text| text.to_string()), + Some("Open settings".to_string()) + ); + }); + + let icon_center = cx + .debug_bounds("git_unavailable_status_icon") + .expect("expected git unavailable status icon") + .center(); + cx.simulate_mouse_move(icon_center, None, gpui::Modifiers::default()); + cx.run_until_parked(); + + cx.update(|_window, app| { + assert_eq!( + test_support::tooltip_text(view.read(app), app), + None, + "expected the open settings tooltip to clear after leaving the button" + ); + }); +} + +#[gpui::test] +fn git_unavailable_overlay_blocks_open_repositories(cx: &mut gpui::TestAppContext) { + let _visual_guard = crate::test_support::lock_visual_test(); + let (store, events) = AppStore::new(Arc::new(TestBackend)); + let (view, cx) = + cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); + + let mut next = AppState { + git_runtime: unavailable_git_runtime_state(), + active_repo: Some(RepoId(1)), + ..AppState::default() + }; + next.repos.push(open_repo_state_with_workdir( + "/tmp/git-unavailable-overlay-test", + )); + let next = Arc::new(next); + + cx.update(|window, app| { + view.update(app, |this, cx| { + this.apply_state_snapshot(Arc::clone(&next), cx); + }); + let _ = window.draw(app); + }); + + cx.debug_bounds("git_unavailable_overlay") + .expect("expected blocking git unavailable overlay"); + + cx.update(|_window, app| { + assert!(!view.read(app).is_splash_screen_active()); + assert!(view.read(app).blocks_non_repository_actions()); + }); +} + +#[gpui::test] +fn git_unavailable_overlay_clears_after_runtime_recovery(cx: &mut gpui::TestAppContext) { + let _visual_guard = crate::test_support::lock_visual_test(); + let (store, events) = AppStore::new(Arc::new(TestBackend)); + let (view, cx) = + cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); + + let mut unavailable = AppState { + git_runtime: unavailable_git_runtime_state(), + active_repo: Some(RepoId(1)), + ..AppState::default() + }; + unavailable.repos.push(open_repo_state_with_workdir( + "/tmp/git-unavailable-recovery-test", + )); + let unavailable = Arc::new(unavailable); + + let mut recovered = AppState { + git_runtime: available_git_runtime_state(), + active_repo: Some(RepoId(1)), + ..AppState::default() + }; + recovered.repos.push(open_repo_state_with_workdir( + "/tmp/git-unavailable-recovery-test", + )); + let recovered = Arc::new(recovered); + + cx.update(|window, app| { + view.update(app, |this, cx| { + this.apply_state_snapshot(Arc::clone(&unavailable), cx); + }); + let _ = window.draw(app); + }); + cx.debug_bounds("git_unavailable_overlay") + .expect("expected overlay before runtime recovery"); + + cx.update(|window, app| { + view.update(app, |this, cx| { + this.apply_state_snapshot(Arc::clone(&recovered), cx); + }); + let _ = window.draw(app); + }); + + assert!( + cx.debug_bounds("git_unavailable_overlay").is_none(), + "expected overlay to disappear after runtime recovery" + ); + cx.update(|_window, app| { + assert!(!view.read(app).blocks_non_repository_actions()); + }); +} + #[gpui::test] fn splash_backdrop_renders_native_layers(cx: &mut gpui::TestAppContext) { + let _visual_guard = crate::test_support::lock_visual_test(); let (store, events) = AppStore::new(Arc::new(TestBackend)); let (view, cx) = cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); @@ -1722,6 +1988,7 @@ fn splash_backdrop_renders_native_layers(cx: &mut gpui::TestAppContext) { #[gpui::test] fn splash_screen_buttons_publish_expected_tooltips(cx: &mut gpui::TestAppContext) { + let _visual_guard = crate::test_support::lock_visual_test(); let (store, events) = AppStore::new(Arc::new(TestBackend)); let (view, cx) = cx.add_window_view(|window, cx| GitCometView::new(store, events, None, window, cx)); @@ -1759,6 +2026,7 @@ fn splash_screen_buttons_publish_expected_tooltips(cx: &mut gpui::TestAppContext #[gpui::test] fn closing_last_repository_tab_returns_to_splash_screen(cx: &mut gpui::TestAppContext) { + let _visual_guard = crate::test_support::lock_visual_test(); let (store, events) = AppStore::new(Arc::new(TestBackend)); let store_for_assert = store.clone(); let (view, cx) = @@ -1827,6 +2095,7 @@ fn closing_last_repository_tab_returns_to_splash_screen(cx: &mut gpui::TestAppCo #[gpui::test] fn splash_screen_clears_stale_close_repository_tooltip(cx: &mut gpui::TestAppContext) { + let _visual_guard = crate::test_support::lock_visual_test(); let (store, events) = AppStore::new(Arc::new(TestBackend)); let store_for_assert = store.clone(); let (view, cx) = @@ -1907,6 +2176,7 @@ fn auth_prompt_banner_colors_use_accent_palette() { fn apply_state_snapshot_routes_command_errors_into_store_backed_banner( cx: &mut gpui::TestAppContext, ) { + let _visual_guard = crate::test_support::lock_visual_test(); let (store, events) = AppStore::new(Arc::new(TestBackend)); let store_for_assert = store.clone(); let (view, cx) = diff --git a/crates/gitcomet-ui-gpui/src/view/toast_host.rs b/crates/gitcomet-ui-gpui/src/view/toast_host.rs index bc5ac3c9..64dc9872 100644 --- a/crates/gitcomet-ui-gpui/src/view/toast_host.rs +++ b/crates/gitcomet-ui-gpui/src/view/toast_host.rs @@ -11,6 +11,11 @@ pub(super) struct ToastHost { clone_progress_dest: Option>, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ToastViewportCorner { + BottomRight, +} + #[derive(Debug, Eq, PartialEq)] struct CloneProgressSyncAction { progress_changed: bool, @@ -25,6 +30,10 @@ fn clone_progress_shell_accent_color(theme: AppTheme) -> gpui::Rgba { with_alpha(theme.colors.accent, if theme.is_dark { 0.20 } else { 0.14 }) } +fn toast_viewport_corner() -> ToastViewportCorner { + ToastViewportCorner::BottomRight +} + fn looks_like_code_message(message: &str) -> bool { message.lines().any(|line| line.starts_with(" ")) } @@ -608,20 +617,29 @@ impl Render for ToastHost { children.push(progress_toast); } - div() + let root = div() .id("toast_layer") - .on_any_mouse_down(|_e, _w, cx| cx.stop_propagation()) - .occlude() .absolute() - .right_0() - .bottom_0() + .top_0() + .left_0() + .size_full() .p(px(16.0)) .flex() - .flex_col() - .items_end() - .gap(px(12.0)) - .children(children) - .into_any_element() + .child( + div() + .id("toast_stack") + .on_any_mouse_down(|_e, _w, cx| cx.stop_propagation()) + .occlude() + .flex() + .flex_col() + .items_end() + .gap(px(12.0)) + .children(children), + ); + + match toast_viewport_corner() { + ToastViewportCorner::BottomRight => root.justify_end().items_end().into_any_element(), + } } } @@ -921,4 +939,9 @@ mod tests { with_alpha(light.colors.accent, 0.14) ); } + + #[test] + fn toast_stack_anchor_is_bottom_right() { + assert_eq!(toast_viewport_corner(), ToastViewportCorner::BottomRight); + } } diff --git a/crates/gitcomet-ui-gpui/src/view/tooltip.rs b/crates/gitcomet-ui-gpui/src/view/tooltip.rs index 234bed4a..cf7be6e4 100644 --- a/crates/gitcomet-ui-gpui/src/view/tooltip.rs +++ b/crates/gitcomet-ui-gpui/src/view/tooltip.rs @@ -27,10 +27,19 @@ impl GitCometView { let sidebar_width: f32 = this.sidebar_width.round().into(); let details_width: f32 = this.details_width.round().into(); - let (history_show_author, history_show_date, history_show_sha) = this + let ( + history_show_graph, + history_show_author, + history_show_date, + history_show_sha, + ) = this .main_pane .read(cx) .history_visible_column_preferences(cx); + let (history_show_tags, history_auto_fetch_tags_on_repo_activation) = this + .main_pane + .read(cx) + .history_tag_preferences(cx); let (change_tracking_height, untracked_height) = this.details_pane.read(cx).saved_status_section_heights(); let repo_sidebar_collapsed_items = @@ -56,9 +65,18 @@ impl GitCometView { diff_scroll_sync: Some(this.diff_scroll_sync.key().to_string()), change_tracking_height, untracked_height, + history_show_graph: Some(history_show_graph), history_show_author: Some(history_show_author), history_show_date: Some(history_show_date), history_show_sha: Some(history_show_sha), + history_show_tags: Some(history_show_tags), + history_tag_fetch_mode: Some(if history_auto_fetch_tags_on_repo_activation + { + gitcomet_state::model::GitLogTagFetchMode::OnRepositoryActivation + } else { + gitcomet_state::model::GitLogTagFetchMode::Disabled + }), + git_executable_path: None, }; Some(settings) diff --git a/crates/gitcomet/Cargo.toml b/crates/gitcomet/Cargo.toml index c10375f4..653d7f4d 100644 --- a/crates/gitcomet/Cargo.toml +++ b/crates/gitcomet/Cargo.toml @@ -30,6 +30,7 @@ clap = { workspace = true } gitcomet-core = { workspace = true } gitcomet-git = { workspace = true } gitcomet-git-gix = { workspace = true, optional = true } +gitcomet-state = { workspace = true } gitcomet-ui = { workspace = true, optional = true } gitcomet-ui-gpui = { workspace = true, optional = true } mimalloc = { workspace = true } diff --git a/crates/gitcomet/src/cli/git_config.rs b/crates/gitcomet/src/cli/git_config.rs index 96e05c2c..6aad85c8 100644 --- a/crates/gitcomet/src/cli/git_config.rs +++ b/crates/gitcomet/src/cli/git_config.rs @@ -1,10 +1,8 @@ use super::*; -use gitcomet_core::process::configure_background_command; +use gitcomet_core::process::git_command as process_git_command; fn git_command() -> std::process::Command { - let mut command = std::process::Command::new("git"); - configure_background_command(&mut command); - command + process_git_command() } fn trim_git_stdout_bytes(bytes: &[u8]) -> &[u8] { diff --git a/crates/gitcomet/src/difftool_mode.rs b/crates/gitcomet/src/difftool_mode.rs index f9c2c4bd..672681c9 100644 --- a/crates/gitcomet/src/difftool_mode.rs +++ b/crates/gitcomet/src/difftool_mode.rs @@ -1,9 +1,8 @@ use crate::cli::{DifftoolConfig, DifftoolInputKind, classify_difftool_input, exit_code}; -use gitcomet_core::process::configure_background_command; +use gitcomet_core::process::git_command; use rustc_hash::FxHashSet as HashSet; use std::fs; use std::path::{Path, PathBuf}; -use std::process::Command; use tempfile::{Builder, TempDir}; /// Format a `"Failed to {op} {path}: {err}"` message concisely. @@ -44,8 +43,7 @@ pub struct DifftoolRunResult { pub fn run_difftool(config: &DifftoolConfig) -> Result { let prepared_inputs = prepare_diff_inputs(config)?; - let mut cmd = Command::new("git"); - configure_background_command(&mut cmd); + let mut cmd = git_command(); cmd.arg("diff").arg("--no-index").arg("--no-ext-diff"); // When launched from `git difftool`, Git sets `GIT_EXTERNAL_DIFF` to its // helper. Remove it so this nested `git diff --no-index` cannot recurse. diff --git a/crates/gitcomet/src/main.rs b/crates/gitcomet/src/main.rs index a673b0aa..7650cd33 100644 --- a/crates/gitcomet/src/main.rs +++ b/crates/gitcomet/src/main.rs @@ -21,6 +21,7 @@ mod mergetool_mode; mod setup_mode; use cli::{AppMode, exit_code}; +use gitcomet_core::process::install_git_executable_path; #[cfg(all(target_os = "linux", feature = "ui-gpui-runtime"))] use linux_wayland_fallback::maybe_relaunch_with_linux_x11_fallback; use mimalloc::MiMalloc; @@ -156,6 +157,8 @@ fn main() { } }; + install_configured_git_executable_preference(&mode); + #[cfg(all(target_os = "linux", feature = "ui-gpui-runtime"))] if let Some(code) = maybe_relaunch_with_linux_x11_fallback(&mode) { std::process::exit(code); @@ -300,6 +303,22 @@ fn main() { } } +fn mode_uses_configured_git_executable_preference(mode: &AppMode) -> bool { + // The persisted custom Git executable is a browser-window preference. + // Git-invoked command modes intentionally keep using `git` from PATH so + // they track the invoking Git installation rather than browser settings. + matches!(mode, AppMode::Browser { .. }) +} + +fn install_configured_git_executable_preference(mode: &AppMode) { + if !mode_uses_configured_git_executable_preference(mode) { + return; + } + + let session = gitcomet_state::session::load(); + let _ = install_git_executable_path(session.git_executable_path); +} + #[cfg(all(target_os = "macos", feature = "ui-gpui-runtime"))] const MACOS_BUNDLE_RELAUNCH_ENV: &str = "GITCOMET_SKIP_APP_BUNDLE_RELAUNCH"; #[cfg(all(target_os = "macos", feature = "ui-gpui-runtime"))] @@ -739,6 +758,59 @@ mod tests { assert!(!should_launch_focused_diff_gui(&config, &result)); } + #[test] + fn configured_git_preference_is_intentionally_browser_only() { + assert!(mode_uses_configured_git_executable_preference( + &AppMode::Browser { path: None } + )); + assert!(!mode_uses_configured_git_executable_preference( + &AppMode::Difftool(cli::DifftoolConfig { + local: std::path::PathBuf::from("left.txt"), + remote: std::path::PathBuf::from("right.txt"), + display_path: None, + label_left: None, + label_right: None, + gui: false, + }) + )); + assert!(!mode_uses_configured_git_executable_preference( + &AppMode::Mergetool(cli::MergetoolConfig { + merged: std::path::PathBuf::from("merged.txt"), + local: std::path::PathBuf::from("local.txt"), + remote: std::path::PathBuf::from("remote.txt"), + base: Some(std::path::PathBuf::from("base.txt")), + label_base: None, + label_local: None, + label_remote: None, + conflict_style: ConflictStyle::Merge, + diff_algorithm: DiffAlgorithm::Myers, + marker_size: DEFAULT_MARKER_SIZE, + auto: false, + gui: false, + }) + )); + assert!(!mode_uses_configured_git_executable_preference( + &AppMode::Setup { + dry_run: false, + local: false, + } + )); + assert!(!mode_uses_configured_git_executable_preference( + &AppMode::Uninstall { + dry_run: false, + local: false, + } + )); + assert!(!mode_uses_configured_git_executable_preference( + &AppMode::ExtractMergeFixtures(cli::ExtractMergeFixturesConfig { + repo: std::path::PathBuf::from("/tmp/repo"), + output_dir: std::path::PathBuf::from("/tmp/out"), + max_merges: 10, + max_files_per_merge: 5, + }) + )); + } + #[test] #[cfg(feature = "ui-gpui-runtime")] fn build_focused_mergetool_gui_config_uses_default_labels() { diff --git a/crates/gitcomet/src/setup_mode.rs b/crates/gitcomet/src/setup_mode.rs index 05f1ad31..95e36400 100644 --- a/crates/gitcomet/src/setup_mode.rs +++ b/crates/gitcomet/src/setup_mode.rs @@ -5,7 +5,7 @@ //! Uninstall removes those entries while preserving unrelated tool settings. use gitcomet_core::path_utils::strip_windows_verbatim_prefix; -use gitcomet_core::process::configure_background_command; +use gitcomet_core::process::git_command as process_git_command; use rustc_hash::FxHashMap as HashMap; use std::path::{Path, PathBuf}; @@ -116,9 +116,7 @@ fn quoted_env_var(name: &str) -> String { } fn git_command() -> std::process::Command { - let mut command = std::process::Command::new("git"); - configure_background_command(&mut command); - command + process_git_command() } /// Build the list of git config entries for difftool/mergetool setup. diff --git a/crates/win32-window-utils/Cargo.lock b/crates/win32-window-utils/Cargo.lock index 968e9955..c886df20 100644 --- a/crates/win32-window-utils/Cargo.lock +++ b/crates/win32-window-utils/Cargo.lock @@ -6,151 +6,20 @@ version = 4 name = "gitcomet-win32-window-utils" version = "0.1.7" dependencies = [ - "windows", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-link", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows-sys", ] [[package]] name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core", - "windows-link", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link", -] +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows-threading" -version = "0.1.0" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] diff --git a/crates/win32-window-utils/Cargo.toml b/crates/win32-window-utils/Cargo.toml index 17f5774c..0b2511b6 100644 --- a/crates/win32-window-utils/Cargo.toml +++ b/crates/win32-window-utils/Cargo.toml @@ -12,4 +12,4 @@ publish = false path = "src/lib.rs" [dependencies] -windows = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging"] } +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging"] } diff --git a/crates/win32-window-utils/src/lib.rs b/crates/win32-window-utils/src/lib.rs index 27aead33..e9d0eae8 100644 --- a/crates/win32-window-utils/src/lib.rs +++ b/crates/win32-window-utils/src/lib.rs @@ -1,9 +1,9 @@ -use std::ffi::c_void; +use std::ptr::null; -use windows::Win32::Foundation::{HWND, LPARAM, POINT, WPARAM}; -use windows::Win32::Graphics::Gdi::ClientToScreen; -use windows::Win32::UI::WindowsAndMessaging::{ - EnableMenuItem, GWL_STYLE, GetSystemMenu, GetWindowLongPtrW, IsIconic, IsZoomed, +use windows_sys::Win32::Foundation::{HWND, LPARAM, POINT, WPARAM}; +use windows_sys::Win32::Graphics::Gdi::ClientToScreen; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + EnableMenuItem, GWL_STYLE, GetSystemMenu, GetWindowLongPtrW, HMENU, IsIconic, IsZoomed, MENU_ITEM_FLAGS, MF_BYCOMMAND, MF_ENABLED, MF_GRAYED, PostMessageW, SC_CLOSE, SC_MAXIMIZE, SC_MINIMIZE, SC_MOVE, SC_RESTORE, SC_SIZE, SW_RESTORE, SetForegroundWindow, ShowWindowAsync, TPM_LEFTALIGN, TPM_RETURNCMD, TPM_RIGHTBUTTON, TPM_TOPALIGN, TrackPopupMenuEx, WINDOW_STYLE, @@ -12,8 +12,8 @@ use windows::Win32::UI::WindowsAndMessaging::{ /// Restore a Win32 window from the maximized state. pub fn restore_window(hwnd: isize) -> bool { - let hwnd = HWND(hwnd as *mut c_void); - unsafe { ShowWindowAsync(hwnd, SW_RESTORE).as_bool() } + let hwnd = hwnd as HWND; + unsafe { ShowWindowAsync(hwnd, SW_RESTORE) != 0 } } #[derive(Debug, Clone, Copy, Eq, PartialEq)] @@ -26,16 +26,20 @@ struct SystemMenuState { close: bool, } +fn has_style(style: WINDOW_STYLE, flag: WINDOW_STYLE) -> bool { + style & flag == flag +} + fn system_menu_state( style: WINDOW_STYLE, is_minimized: bool, is_maximized: bool, ) -> SystemMenuState { - let has_system_menu = style.contains(WS_SYSMENU); + let has_system_menu = has_style(style, WS_SYSMENU); let is_restored = !is_minimized && !is_maximized; - let can_resize = style.contains(WS_THICKFRAME); - let has_minimize = style.contains(WS_MINIMIZEBOX); - let has_maximize = style.contains(WS_MAXIMIZEBOX); + let can_resize = has_style(style, WS_THICKFRAME); + let has_minimize = has_style(style, WS_MINIMIZEBOX); + let has_maximize = has_style(style, WS_MAXIMIZEBOX); SystemMenuState { restore: has_system_menu && !is_restored, @@ -47,21 +51,17 @@ fn system_menu_state( } } -fn enable_menu_item( - menu: windows::Win32::UI::WindowsAndMessaging::HMENU, - command: u32, - enabled: bool, -) { +fn enable_menu_item(menu: HMENU, command: u32, enabled: bool) { let flags: MENU_ITEM_FLAGS = MF_BYCOMMAND | if enabled { MF_ENABLED } else { MF_GRAYED }; unsafe { let _ = EnableMenuItem(menu, command, flags); } } -fn sync_system_menu_state(hwnd: HWND, menu: windows::Win32::UI::WindowsAndMessaging::HMENU) { - let style = WINDOW_STYLE(unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) } as u32); - let state = system_menu_state(style, unsafe { IsIconic(hwnd).as_bool() }, unsafe { - IsZoomed(hwnd).as_bool() +fn sync_system_menu_state(hwnd: HWND, menu: HMENU) { + let style = unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) as WINDOW_STYLE }; + let state = system_menu_state(style, unsafe { IsIconic(hwnd) != 0 }, unsafe { + IsZoomed(hwnd) != 0 }); enable_menu_item(menu, SC_RESTORE, state.restore); @@ -74,16 +74,16 @@ fn sync_system_menu_state(hwnd: HWND, menu: windows::Win32::UI::WindowsAndMessag /// Show the native Win32 system menu for a window at the given client-area position. pub fn show_window_system_menu(hwnd: isize, x: i32, y: i32) { - let hwnd = HWND(hwnd as *mut c_void); + let hwnd = hwnd as HWND; let mut position = POINT { x, y }; unsafe { - if !ClientToScreen(hwnd, &mut position).as_bool() { + if ClientToScreen(hwnd, &mut position) == 0 { return; } - let menu = GetSystemMenu(hwnd, false); - if menu.0.is_null() { + let menu = GetSystemMenu(hwnd, 0); + if menu.is_null() { return; } @@ -91,22 +91,16 @@ pub fn show_window_system_menu(hwnd: isize, x: i32, y: i32) { let _ = SetForegroundWindow(hwnd); let command = TrackPopupMenuEx( menu, - (TPM_LEFTALIGN | TPM_TOPALIGN | TPM_RIGHTBUTTON | TPM_RETURNCMD).0, + TPM_LEFTALIGN | TPM_TOPALIGN | TPM_RIGHTBUTTON | TPM_RETURNCMD, position.x, position.y, hwnd, - None, - ) - .0 as usize; + null(), + ) as usize; - let _ = PostMessageW(Some(hwnd), WM_NULL, WPARAM::default(), LPARAM::default()); + let _ = PostMessageW(hwnd, WM_NULL, WPARAM::default(), LPARAM::default()); if command != 0 { - let _ = PostMessageW( - Some(hwnd), - WM_SYSCOMMAND, - WPARAM(command), - LPARAM::default(), - ); + let _ = PostMessageW(hwnd, WM_SYSCOMMAND, command as WPARAM, LPARAM::default()); } } } diff --git a/scripts/cargo-flatten-dupes.sh b/scripts/cargo-flatten-dupes.sh new file mode 100755 index 00000000..3ddf53cb --- /dev/null +++ b/scripts/cargo-flatten-dupes.sh @@ -0,0 +1,1073 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SELF_REL="scripts/cargo-flatten-dupes.sh" +DEFAULT_MANIFEST_PATH="$ROOT_DIR/Cargo.toml" +WHY_TREE_MAX_LINES=40 + +MODE="" +JSON_OUTPUT=0 +INCLUDE_DEV=0 +MANIFEST_PATH="$DEFAULT_MANIFEST_PATH" +FILTER_CRATE="" +TREE_EDGES="normal,build" + +TMP_FILES=() +ALL_METADATA_FILE="" +HOST_METADATA_FILE="" +ANALYSIS_FILE="" +WORKSPACE_ROOT="" +WORKSPACE_MANIFEST_PATH="" +HOST_TRIPLE="" +ANALYSIS_READY=0 + +usage() { + cat <<'EOF' +Usage: scripts/cargo-flatten-dupes.sh [options] [crate] + +Audit duplicate resolved Cargo crates, explain which requirement keeps a lower +version alive, and suggest local flattening steps where possible. + +Commands: + summary List duplicate crates with blocker summaries. + why Show incoming requirements and reverse-tree excerpts for a duplicate crate. + suggest [crate] Emit non-mutating local fix hints for one duplicate crate or all of them. + +Options: + --manifest-path PATH Cargo manifest to analyze. Default: workspace root Cargo.toml + --include-dev Include dev-dependencies in reachability and blocker analysis. + --json Emit machine-readable JSON for the selected command. + -h, --help Show this help. + +Notes: + - Analysis uses `cargo metadata --locked --offline` and does not edit files. + - Target scope defaults to all targets. This catches platform-specific skew. + - `suggest` reports local manifest or lockfile actions only when they can + remove a duplicate without relying on upstream changes. + +Examples: + scripts/cargo-flatten-dupes.sh summary + scripts/cargo-flatten-dupes.sh why rustix + scripts/cargo-flatten-dupes.sh suggest windows + scripts/cargo-flatten-dupes.sh --json suggest +EOF +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +cleanup() { + if ((${#TMP_FILES[@]} == 0)); then + return + fi + + rm -f "${TMP_FILES[@]}" +} + +trap cleanup EXIT + +make_tmp() { + local tmp + tmp="$(mktemp)" + TMP_FILES+=("$tmp") + printf '%s\n' "$tmp" +} + +require_tool() { + local tool="$1" + command -v "$tool" >/dev/null 2>&1 || die "$tool is required." +} + +trim() { + local value="$1" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "$value" +} + +normalize_version() { + printf '%s\n' "${1%%+*}" +} + +version_compare() { + local left right max + left="$(normalize_version "$1")" + right="$(normalize_version "$2")" + + if [[ "$left" == "$right" ]]; then + printf '0\n' + return + fi + + max="$(printf '%s\n%s\n' "$left" "$right" | sort -V | tail -n 1)" + if [[ "$max" == "$left" ]]; then + printf '1\n' + else + printf '%s\n' '-1' + fi +} + +version_ge() { + [[ "$(version_compare "$1" "$2")" != "-1" ]] +} + +version_gt() { + [[ "$(version_compare "$1" "$2")" == "1" ]] +} + +version_le() { + [[ "$(version_compare "$1" "$2")" != "1" ]] +} + +version_lt() { + [[ "$(version_compare "$1" "$2")" == "-1" ]] +} + +parse_req_base() { + local raw="$1" + local core + local -a parts=() + + raw="${raw%%+*}" + core="${raw%%-*}" + IFS='.' read -r -a parts <<<"$core" + + printf '%s\t%s\t%s\t%s\n' \ + "${#parts[@]}" \ + "${parts[0]:-0}" \ + "${parts[1]:-0}" \ + "${parts[2]:-0}" +} + +caret_upper_bound() { + local count major minor patch + IFS=$'\t' read -r count major minor patch <<<"$(parse_req_base "$1")" + + if ((major > 0)); then + printf '%s.0.0\n' "$((major + 1))" + elif ((minor > 0)); then + printf '0.%s.0\n' "$((minor + 1))" + else + printf '0.0.%s\n' "$((patch + 1))" + fi +} + +tilde_upper_bound() { + local count major minor patch + IFS=$'\t' read -r count major minor patch <<<"$(parse_req_base "$1")" + + if ((count <= 1)); then + printf '%s.0.0\n' "$((major + 1))" + else + printf '%s.%s.0\n' "$major" "$((minor + 1))" + fi +} + +req_clause_allows_version() { + local clause="$1" + local version="$2" + local count major minor patch lower upper rest + + clause="$(trim "$clause")" + version="$(normalize_version "$version")" + + if [[ -z "$clause" ]]; then + return 0 + fi + + case "$clause" in + \*) + return 0 + ;; + \^*) + rest="$(trim "${clause#^}")" + IFS=$'\t' read -r count major minor patch <<<"$(parse_req_base "$rest")" + lower="${major}.${minor}.${patch}" + upper="$(caret_upper_bound "$rest")" + version_ge "$version" "$lower" && version_lt "$version" "$upper" + return + ;; + \~*) + rest="$(trim "${clause#\~}")" + IFS=$'\t' read -r count major minor patch <<<"$(parse_req_base "$rest")" + lower="${major}.${minor}.${patch}" + upper="$(tilde_upper_bound "$rest")" + version_ge "$version" "$lower" && version_lt "$version" "$upper" + return + ;; + \>=*) + rest="$(trim "${clause#>=}")" + version_ge "$version" "$rest" + return + ;; + \>*) + rest="$(trim "${clause#>}")" + version_gt "$version" "$rest" + return + ;; + \<\=*) + rest="$(trim "${clause#<=}")" + version_le "$version" "$rest" + return + ;; + \<*) + rest="$(trim "${clause#<}")" + version_lt "$version" "$rest" + return + ;; + \=*) + rest="$(trim "${clause#=}")" + [[ "$(normalize_version "$rest")" == "$version" ]] + return + ;; + esac + + if [[ "$clause" == *"*"* ]]; then + IFS=$'\t' read -r _ major minor patch <<<"$(parse_req_base "${clause//\*/0}")" + IFS='.' read -r -a version_parts <<<"${version%%-*}" + + case "$clause" in + *.*.*) + [[ "${version_parts[0]:-0}" == "$major" && "${version_parts[1]:-0}" == "$minor" && "${version_parts[2]:-0}" == "$patch" ]] + ;; + *.*) + [[ "${version_parts[0]:-0}" == "$major" && "${version_parts[1]:-0}" == "$minor" ]] + ;; + *) + [[ "${version_parts[0]:-0}" == "$major" ]] + ;; + esac + return + fi + + if [[ "$clause" == *"||"* ]]; then + return 1 + fi + + IFS=$'\t' read -r count major minor patch <<<"$(parse_req_base "$clause")" + lower="${major}.${minor}.${patch}" + upper="$(caret_upper_bound "$clause")" + version_ge "$version" "$lower" && version_lt "$version" "$upper" +} + +req_allows_version() { + local req="$1" + local version="$2" + local clause + local -a clauses=() + + req="$(trim "$req")" + if [[ -z "$req" || "$req" == *"||"* ]]; then + return 1 + fi + + IFS=',' read -r -a clauses <<<"$req" + for clause in "${clauses[@]}"; do + clause="$(trim "$clause")" + if ! req_clause_allows_version "$clause" "$version"; then + return 1 + fi + done + + return 0 +} + +humanize_status() { + case "$1" in + locally-fixable) + printf 'locally-fixable' + ;; + platform-specific) + printf 'platform-specific' + ;; + upstream-blocked) + printf 'upstream-blocked' + ;; + source-mismatch) + printf 'source-mismatch' + ;; + *) + printf '%s' "$1" + ;; + esac +} + +display_path() { + local path="$1" + + if [[ -n "$WORKSPACE_ROOT" && "$path" == "$WORKSPACE_ROOT/"* ]]; then + printf '%s\n' "${path#$WORKSPACE_ROOT/}" + else + printf '%s\n' "$path" + fi +} + +manifest_dep_mode() { + local manifest="$1" + local dep="$2" + local snippet + + if [[ ! -f "$manifest" ]]; then + printf 'unknown\n' + return + fi + + snippet="$( + awk -v dep="$dep" ' + BEGIN { + capturing = 0 + open_count = 0 + close_count = 0 + } + { + if (!capturing && $0 ~ "^[[:space:]]*" dep "[[:space:]]*=") { + capturing = 1 + } + + if (!capturing) { + next + } + + print + open_count += gsub(/\{/, "{") + close_count += gsub(/\}/, "}") + + if (open_count == 0 || close_count >= open_count) { + exit + } + } + ' "$manifest" + )" + + if [[ -z "$snippet" ]]; then + printf 'unknown\n' + return + fi + + if grep -Eq 'workspace[[:space:]]*=[[:space:]]*true' <<<"$snippet"; then + printf 'workspace\n' + else + printf 'explicit\n' + fi +} + +parse_args() { + local -a positionals=() + + while (($# > 0)); do + case "$1" in + summary|why|suggest) + if [[ -n "$MODE" ]]; then + die "multiple commands given: $MODE and $1" + fi + MODE="$1" + ;; + --manifest-path) + shift + (($# > 0)) || die "--manifest-path requires a value" + MANIFEST_PATH="$1" + ;; + --include-dev) + INCLUDE_DEV=1 + ;; + --json) + JSON_OUTPUT=1 + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift + positionals+=("$@") + break + ;; + -*) + die "unknown option: $1" + ;; + *) + positionals+=("$1") + ;; + esac + shift + done + + [[ -n "$MODE" ]] || die "missing command" + + case "$MODE" in + summary) + ((${#positionals[@]} == 0)) || die "summary does not take a crate name" + ;; + why) + ((${#positionals[@]} == 1)) || die "why requires exactly one crate name" + FILTER_CRATE="${positionals[0]}" + ;; + suggest) + ((${#positionals[@]} <= 1)) || die "suggest takes zero or one crate name" + FILTER_CRATE="${positionals[0]:-}" + ;; + esac +} + +collect_metadata() { + HOST_TRIPLE="$(rustc -vV | sed -n 's/^host: //p')" + [[ -n "$HOST_TRIPLE" ]] || die "failed to determine the host target triple" + + cargo metadata --format-version 1 --locked --offline --manifest-path "$MANIFEST_PATH" >"$ALL_METADATA_FILE" + cargo metadata --format-version 1 --locked --offline --manifest-path "$MANIFEST_PATH" --filter-platform "$HOST_TRIPLE" >"$HOST_METADATA_FILE" +} + +build_analysis() { + jq -n \ + --slurpfile all "$ALL_METADATA_FILE" \ + --slurpfile host "$HOST_METADATA_FILE" \ + --argjson include_dev "$INCLUDE_DEV" ' +def kind_label($kind): + if $kind == null then "normal" else $kind end; +def target_label($target): + if $target == null then "all" else $target end; +def source_label($pkg): + if $pkg.source == null then "path" else $pkg.source end; +def source_family($pkg): + if $pkg.source == null then "path" + elif ($pkg.source | startswith("registry+")) then "crates.io" + elif ($pkg.source | startswith("git+")) then "git" + else $pkg.source + end; +def semver_capture($version): + try ( + $version + | capture("^(?[0-9]+)\\.(?[0-9]+)\\.(?[0-9]+)(?:-(?
[^+]+))?(?:\\+(?.*))?$")
+  ) catch {
+    "maj": "0",
+    "min": "0",
+    "patch": "0",
+    "pre": "~",
+    "build": ""
+  };
+def semver_key($version):
+  (semver_capture($version)) as $m
+  | [
+      ($m.maj | tonumber),
+      ($m.min | tonumber),
+      ($m.patch | tonumber),
+      (if ($m.pre // null) == null then 1 else 0 end),
+      ($m.pre // ""),
+      ($m.build // "")
+    ];
+def allowed_dep_kinds($dep_kinds):
+  [
+    $dep_kinds[]?
+    | select(
+        (kind_label(.kind) == "normal")
+        or (kind_label(.kind) == "build")
+        or ($include_dev and kind_label(.kind) == "dev")
+      )
+  ];
+def reachable_ids($metadata):
+  ($metadata.resolve.nodes // []) as $nodes
+  | ($nodes
+      | map({
+          key: .id,
+          value: [
+            .deps[]?
+            | select((allowed_dep_kinds(.dep_kinds) | length) > 0)
+            | .pkg
+          ]
+        })
+      | from_entries) as $adj
+  | def closure($frontier; $seen):
+      if ($frontier | length) == 0 then ($seen | unique)
+      else
+        ([ $frontier[] | $adj[.][]? ] | unique) as $candidates
+        | [ $candidates[] as $candidate | select($seen | index($candidate) | not) | $candidate ] as $next
+        | closure($next; (($seen + $next) | unique))
+      end;
+    closure(($metadata.workspace_members // []); (($metadata.workspace_members // []) | unique));
+($all[0]) as $meta
+| ($host[0]) as $host_meta
+| (reachable_ids($meta)) as $reachable
+| (reachable_ids($host_meta)) as $host_reachable
+| ($meta.packages | map(. as $pkg | select($reachable | index($pkg.id))) | sort_by(.name, .version)) as $packages
+| ($packages | map({key: .id, value: .}) | from_entries) as $pkg_map
+| (
+    [
+      ($meta.resolve.nodes // [])[] as $node
+      | select($reachable | index($node.id))
+      | ($pkg_map[$node.id]) as $from
+      | $node.deps[]? as $edge
+      | (allowed_dep_kinds($edge.dep_kinds)) as $allowed
+      | select(($allowed | length) > 0)
+      | select($reachable | index($edge.pkg))
+      | ($pkg_map[$edge.pkg]) as $to
+      | ($from.dependencies
+          | map(select((.rename // .name) == $edge.name))) as $same_name_decls
+      | ($same_name_decls
+          | map(
+              . as $decl
+              | select(
+                  if ($allowed | length) == 0 then true
+                  else any(
+                    $allowed[];
+                    (($decl.kind // "normal") == kind_label(.kind))
+                    and (($decl.target // "all") == target_label(.target))
+                  )
+                  end
+                )
+            )) as $matched_decls
+      | {
+          to_id: $edge.pkg,
+          to_name: $to.name,
+          from_id: $node.id,
+          from_name: $from.name,
+          from_version: $from.version,
+          from_source: source_label($from),
+          from_source_family: source_family($from),
+          from_manifest_path: ($from.manifest_path // ""),
+          from_editable: (
+            $from.source == null
+            and (($from.manifest_path // "") | startswith($meta.workspace_root + "/"))
+          ),
+          dep_name: $edge.name,
+          reqs: (
+            if ($matched_decls | length) > 0 then ($matched_decls | map(.req) | unique | sort)
+            elif ($same_name_decls | length) > 0 then ($same_name_decls | map(.req) | unique | sort)
+            else ["unknown"]
+            end
+          ),
+          kind_labels: ($allowed | map(kind_label(.kind)) | unique | sort),
+          targets: ($allowed | map(target_label(.target)) | unique | sort),
+          direct_target_specific: ($allowed | any(.target != null))
+        }
+    ]
+  ) as $incoming_edges
+| ($incoming_edges | sort_by(.to_id) | group_by(.to_id) | map({key: .[0].to_id, value: .}) | from_entries) as $incoming_by_to
+| {
+    workspace_root: $meta.workspace_root,
+    manifest_path: ($meta.workspace_root + "/Cargo.toml"),
+    host_triple: "'"$HOST_TRIPLE"'",
+    include_dev: $include_dev,
+    target_scope: "all",
+    duplicates: (
+      $packages
+      | sort_by(.name)
+      | group_by(.name)
+      | map(select((map(.id) | unique | length) > 1))
+      | map(
+          . as $group
+          | ($group | sort_by(semver_key(.version))) as $sorted
+          | ($sorted[-1]) as $candidate_pkg
+          | ($sorted
+              | map(
+                  . as $pkg
+                  | {
+                      id,
+                      version,
+                      source: source_label($pkg),
+                      source_family: source_family($pkg),
+                      host_reachable: ($host_reachable | index($pkg.id) != null),
+                      inbound_edges: ($incoming_by_to[$pkg.id] // [])
+                    }
+                )) as $versions
+          | ($versions[0:-1]) as $lower_versions
+          | ([ $lower_versions[]?.inbound_edges[]? ]) as $lower_blockers
+          | ([ $lower_blockers[] | select(.from_editable) ]) as $local_blockers
+          | ([ $lower_blockers[] | select(.from_editable | not) ]) as $upstream_blockers
+          | ([ $group[] | source_family(.) ] | unique) as $source_families
+          | {
+              name: $group[0].name,
+              status: (
+                if ($source_families | length) > 1 then "source-mismatch"
+                elif (($lower_versions | length) > 0 and ([ $lower_versions[] | select(.host_reachable) ] | length) == 0) then "platform-specific"
+                elif (($lower_blockers | length) > 0 and ($local_blockers | length) == ($lower_blockers | length)) then "locally-fixable"
+                else "upstream-blocked"
+                end
+              ),
+              source_families: $source_families,
+              candidate: {
+                id: $candidate_pkg.id,
+                version: $candidate_pkg.version,
+                source: source_label($candidate_pkg),
+                source_family: source_family($candidate_pkg)
+              },
+              versions: $versions,
+              blockers: $lower_blockers,
+              local_blockers: $local_blockers,
+              upstream_blockers: $upstream_blockers
+            }
+        )
+      | sort_by(
+          (if .status == "locally-fixable" then 0 elif .status == "platform-specific" then 1 elif .status == "upstream-blocked" then 2 else 3 end),
+          .name
+        )
+    )
+  }'
+}
+
+ensure_analysis() {
+  if ((ANALYSIS_READY == 1)); then
+    return
+  fi
+
+  require_tool cargo
+  require_tool jq
+  require_tool rustc
+
+  ALL_METADATA_FILE="$(make_tmp)"
+  HOST_METADATA_FILE="$(make_tmp)"
+  ANALYSIS_FILE="$(make_tmp)"
+  TREE_EDGES="normal,build"
+  if ((INCLUDE_DEV == 1)); then
+    TREE_EDGES="normal,build,dev"
+  fi
+
+  collect_metadata
+  build_analysis >"$ANALYSIS_FILE"
+
+  WORKSPACE_ROOT="$(jq -r '.workspace_root' "$ANALYSIS_FILE")"
+  WORKSPACE_MANIFEST_PATH="$(jq -r '.manifest_path' "$ANALYSIS_FILE")"
+  ANALYSIS_READY=1
+}
+
+duplicate_record_or_die() {
+  local crate="$1"
+  local record
+
+  record="$(jq -c --arg crate "$crate" '.duplicates[] | select(.name == $crate)' "$ANALYSIS_FILE")"
+  [[ -n "$record" ]] || die "no duplicate resolved versions found for crate '$crate'"
+  printf '%s\n' "$record"
+}
+
+cargo_tree_excerpt() {
+  local spec="$1"
+  local tree
+  local line_count
+
+  tree="$(
+    cargo tree \
+      -i "$spec" \
+      --workspace \
+      --locked \
+      --offline \
+      --charset ascii \
+      --target all \
+      --edges "$TREE_EDGES"
+  )"
+
+  line_count="$(printf '%s\n' "$tree" | wc -l | tr -d ' ')"
+  if ((line_count > WHY_TREE_MAX_LINES)); then
+    printf '%s\n' "$tree" | sed -n "1,${WHY_TREE_MAX_LINES}p"
+    printf '...\n'
+  else
+    printf '%s\n' "$tree"
+  fi
+}
+
+emit_summary() {
+  if ((JSON_OUTPUT == 1)); then
+    cat "$ANALYSIS_FILE"
+    return
+  fi
+
+  local count
+  count="$(jq -r '.duplicates | length' "$ANALYSIS_FILE")"
+  if [[ "$count" == "0" ]]; then
+    echo "No duplicate crates found for the selected graph."
+    return
+  fi
+
+  printf 'Duplicate crates (target=all, edges=%s)\n' "$TREE_EDGES"
+  printf '%-19s %-24s %-34s %s\n' "STATUS" "CRATE" "VERSIONS" "DETAIL"
+
+  while IFS=$'\t' read -r status name versions detail; do
+    printf '%-19s %-24s %-34s %s\n' "$status" "$name" "$versions" "$detail"
+  done < <(
+    jq -r '
+      .duplicates[]
+      | [
+          .status,
+          .name,
+          (.versions | map(.version + " [" + .source_family + "]") | join(", ")),
+          (
+            if .status == "source-mismatch" then
+              "sources=" + (.source_families | join(", "))
+            else
+              ([ .blockers[] | "\(.from_name) \(.from_version) -> \(.reqs | join(" | "))" ]
+                | unique
+                | .[0:2]
+                | if length == 0 then "-" else join("; ") end)
+            end
+          )
+        ]
+      | @tsv
+    ' "$ANALYSIS_FILE"
+  )
+
+  printf '\nUse `%s why ` for reverse paths and `%s suggest ` for local actions.\n' "$SELF_REL" "$SELF_REL"
+}
+
+emit_why() {
+  local record_json="$1"
+  local name status candidate
+  local -a version_payloads=()
+  local version
+  local count
+
+  if ((JSON_OUTPUT == 1)); then
+    while IFS= read -r version; do
+      local repeated spec tree
+      repeated="$(jq -r --arg version "$version" '[.versions[] | select(.version == $version)] | length > 1' <<<"$record_json")"
+      spec="$(jq -r --arg version "$version" '.versions[] | select(.version == $version) | .id' <<<"$record_json" | head -n 1)"
+      if [[ "$repeated" == "true" ]]; then
+        tree="reverse tree omitted because multiple source variants share version $version"
+      else
+        spec="$(jq -r --arg version "$version" '.versions[] | select(.version == $version) | .name? // empty' <<<"$record_json")"
+        spec="${FILTER_CRATE}@${version}"
+        tree="$(cargo_tree_excerpt "$spec")"
+      fi
+      version_payloads+=("$(jq -n --arg version "$version" --arg spec "$spec" --arg reverse_tree "$tree" '{version: $version, spec: $spec, reverse_tree: $reverse_tree}')")
+    done < <(jq -r '.versions[].version' <<<"$record_json")
+
+    if ((${#version_payloads[@]} == 0)); then
+      jq -n --argjson duplicate "$record_json" '{duplicate: $duplicate, reverse_trees: []}'
+    else
+      printf '%s\n' "${version_payloads[@]}" | jq -s --argjson duplicate "$record_json" '{duplicate: $duplicate, reverse_trees: .}'
+    fi
+    return
+  fi
+
+  name="$(jq -r '.name' <<<"$record_json")"
+  status="$(jq -r '.status' <<<"$record_json")"
+  candidate="$(jq -r '.candidate.version + " [" + .candidate.source_family + "]"' <<<"$record_json")"
+
+  printf '%s [%s]\n' "$name" "$(humanize_status "$status")"
+  printf 'candidate: %s\n' "$candidate"
+  printf 'versions: %s\n' "$(jq -r '.versions | map(.version + " [" + .source_family + "]") | join(", ")' <<<"$record_json")"
+
+  while IFS= read -r version; do
+    local source_family host_reachable repeated tree spec
+    source_family="$(jq -r --arg version "$version" '.versions[] | select(.version == $version) | .source_family' <<<"$record_json" | head -n 1)"
+    host_reachable="$(jq -r --arg version "$version" '.versions[] | select(.version == $version) | .host_reachable' <<<"$record_json" | head -n 1)"
+    repeated="$(jq -r --arg version "$version" '[.versions[] | select(.version == $version)] | length > 1' <<<"$record_json")"
+
+    printf '\n%s [%s] host-reachable=%s\n' "$version" "$source_family" "$host_reachable"
+    echo "incoming requirements:"
+    while IFS=$'\t' read -r from_name from_version reqs kinds targets editable; do
+      printf '  - %s %s -> %s [kind=%s target=%s editable=%s]\n' "$from_name" "$from_version" "$reqs" "$kinds" "$targets" "$editable"
+    done < <(
+      jq -r --arg version "$version" '
+        .versions[]
+        | select(.version == $version)
+        | .inbound_edges[]
+        | [
+            .from_name,
+            .from_version,
+            (.reqs | join(" | ")),
+            (.kind_labels | join(",")),
+            (.targets | join(",")),
+            (if .from_editable then "yes" else "no" end)
+          ]
+        | @tsv
+      ' <<<"$record_json"
+    )
+
+    echo "reverse tree excerpt:"
+    if [[ "$repeated" == "true" ]]; then
+      echo "  reverse tree omitted because multiple source variants share version $version"
+    else
+      spec="${FILTER_CRATE}@${version}"
+      tree="$(cargo_tree_excerpt "$spec")"
+      sed 's/^/  /' <<<"$tree"
+    fi
+  done < <(jq -r '.versions[].version' <<<"$record_json")
+}
+
+build_upstream_blockers_json() {
+  local record_json="$1"
+
+  jq '
+    [
+      .upstream_blockers[]
+      | {
+          from_name,
+          from_version,
+          reqs: (.reqs | join(" | ")),
+          kinds: (.kind_labels | join(",")),
+          targets: (.targets | join(",")),
+          manifest_path: .from_manifest_path
+        }
+    ]
+    | unique_by([.from_name, .from_version, .reqs, .kinds, .targets, .manifest_path])
+    | sort_by(.from_name, .from_version, .reqs, .targets)
+  ' <<<"$record_json"
+}
+
+build_suggest_payload() {
+  local record_json="$1"
+  local name status candidate_version
+  local local_count upstream_count outcome reason
+  local all_allow=1
+  local -a all_reqs=()
+  local -a local_lines=()
+  local -a suggestion_jsons=()
+  local req mode target_manifest via_manifest key from_name from_version reqs kinds targets current_req
+  local repeated_note=""
+  local upstream_blockers_json
+  declare -A seen=()
+
+  name="$(jq -r '.name' <<<"$record_json")"
+  status="$(jq -r '.status' <<<"$record_json")"
+  candidate_version="$(jq -r '.candidate.version' <<<"$record_json")"
+  local_count="$(jq -r '.local_blockers | length' <<<"$record_json")"
+  upstream_count="$(jq -r '.upstream_blockers | length' <<<"$record_json")"
+  upstream_blockers_json="$(build_upstream_blockers_json "$record_json")"
+
+  case "$status" in
+    source-mismatch)
+      outcome="blocked"
+      reason="resolved from multiple source families: $(jq -r '.source_families | join(", ")' <<<"$record_json")"
+      jq -n --argjson duplicate "$record_json" --arg outcome "$outcome" --arg reason "$reason" --argjson blockers "$upstream_blockers_json" '{duplicate: $duplicate, outcome: $outcome, reason: $reason, blockers: $blockers, suggestions: []}'
+      return
+      ;;
+    upstream-blocked)
+      outcome="blocked"
+      reason="lower-version blockers come from upstream crates"
+      jq -n --argjson duplicate "$record_json" --arg outcome "$outcome" --arg reason "$reason" --argjson blockers "$upstream_blockers_json" '{duplicate: $duplicate, outcome: $outcome, reason: $reason, blockers: $blockers, suggestions: []}'
+      return
+      ;;
+  esac
+
+  if [[ "$local_count" == "0" ]]; then
+    outcome="blocked"
+    reason="no editable first-party blocker manifests were found"
+    jq -n --argjson duplicate "$record_json" --arg outcome "$outcome" --arg reason "$reason" --argjson blockers "$upstream_blockers_json" '{duplicate: $duplicate, outcome: $outcome, reason: $reason, blockers: $blockers, suggestions: []}'
+    return
+  fi
+
+  if [[ "$upstream_count" != "0" ]]; then
+    outcome="blocked"
+    reason="upstream crates still require a lower version, so local edits cannot fully flatten this duplicate"
+    jq -n --argjson duplicate "$record_json" --arg outcome "$outcome" --arg reason "$reason" --argjson blockers "$upstream_blockers_json" '{duplicate: $duplicate, outcome: $outcome, reason: $reason, blockers: $blockers, suggestions: []}'
+    return
+  fi
+
+  while IFS= read -r req; do
+    all_reqs+=("$req")
+  done < <(jq -r '.local_blockers[].reqs[]' <<<"$record_json")
+
+  for req in "${all_reqs[@]}"; do
+    if ! req_allows_version "$req" "$candidate_version"; then
+      all_allow=0
+      break
+    fi
+  done
+
+  if ((all_allow == 1)); then
+    outcome="actionable"
+    reason="all editable requirements already admit the highest resolved version"
+    jq -n \
+      --argjson duplicate "$record_json" \
+      --arg outcome "$outcome" \
+      --arg reason "$reason" \
+      --arg crate "$name" \
+      --arg version "$candidate_version" \
+      '{
+         duplicate: $duplicate,
+         outcome: $outcome,
+         reason: $reason,
+         suggestions: [
+           {
+             type: "cargo-update",
+             crate: $crate,
+             version: $version,
+             command: ("cargo update -p " + $crate + " --precise " + $version)
+           }
+         ]
+       }'
+    return
+  fi
+
+  while IFS=$'\t' read -r via_manifest from_name from_version reqs kinds targets; do
+    mode="$(manifest_dep_mode "$via_manifest" "$name")"
+    target_manifest="$via_manifest"
+    if [[ "$mode" == "workspace" ]]; then
+      target_manifest="$WORKSPACE_MANIFEST_PATH"
+    fi
+
+    key="${target_manifest}|${reqs}"
+    if [[ -n "${seen[$key]:-}" ]]; then
+      continue
+    fi
+    seen[$key]=1
+
+    current_req="$reqs"
+    suggestion_jsons+=("$(
+      jq -n \
+        --arg type "$(if [[ "$mode" == "workspace" ]]; then printf 'workspace-bump'; else printf 'manifest-bump'; fi)" \
+        --arg crate "$name" \
+        --arg target_manifest "$target_manifest" \
+        --arg via_manifest "$via_manifest" \
+        --arg from_requirement "$current_req" \
+        --arg to_requirement "^${candidate_version}" \
+        --arg via_package "${from_name} ${from_version}" \
+        --arg kinds "$kinds" \
+        --arg targets "$targets" \
+        '{
+           type: $type,
+           crate: $crate,
+           target_manifest: $target_manifest,
+           via_manifest: $via_manifest,
+           via_package: $via_package,
+           from_requirement: $from_requirement,
+           to_requirement: $to_requirement,
+           kinds: $kinds,
+           targets: $targets
+         }'
+    )")
+  done < <(
+    jq -r '
+      .local_blockers[]
+      | [
+          .from_manifest_path,
+          .from_name,
+          .from_version,
+          (.reqs | join(" | ")),
+          (.kind_labels | join(",")),
+          (.targets | join(","))
+        ]
+      | @tsv
+    ' <<<"$record_json"
+  )
+
+  if ((${#suggestion_jsons[@]} == 0)); then
+    outcome="blocked"
+    reason="editable blockers were found, but no manifest suggestion could be constructed"
+    jq -n --argjson duplicate "$record_json" --arg outcome "$outcome" --arg reason "$reason" --argjson blockers "$upstream_blockers_json" '{duplicate: $duplicate, outcome: $outcome, reason: $reason, blockers: $blockers, suggestions: []}'
+    return
+  fi
+
+  outcome="actionable"
+  reason="editable blocker requirements need to move to the highest resolved version"
+  printf '%s\n' "${suggestion_jsons[@]}" | jq -s --argjson duplicate "$record_json" --arg outcome "$outcome" --arg reason "$reason" --argjson blockers "$upstream_blockers_json" '{duplicate: $duplicate, outcome: $outcome, reason: $reason, blockers: $blockers, suggestions: .}'
+}
+
+emit_suggest() {
+  local -a payloads=()
+  local record_json payload_json
+
+  if [[ -n "$FILTER_CRATE" ]]; then
+    record_json="$(duplicate_record_or_die "$FILTER_CRATE")"
+    payload_json="$(build_suggest_payload "$record_json")"
+    if ((JSON_OUTPUT == 1)); then
+      printf '%s\n' "$payload_json"
+      return
+    fi
+    payloads=("$payload_json")
+  else
+    while IFS= read -r record_json; do
+      payloads+=("$(build_suggest_payload "$record_json")")
+    done < <(jq -c '.duplicates[]' "$ANALYSIS_FILE")
+
+    if ((JSON_OUTPUT == 1)); then
+      if ((${#payloads[@]} == 0)); then
+        jq -n '{duplicates: []}'
+      else
+        printf '%s\n' "${payloads[@]}" | jq -s '{duplicates: .}'
+      fi
+      return
+    fi
+  fi
+
+  if ((${#payloads[@]} == 0)); then
+    echo "No duplicate crates found for the selected graph."
+    return
+  fi
+
+  for payload_json in "${payloads[@]}"; do
+    local name status outcome reason
+    name="$(jq -r '.duplicate.name' <<<"$payload_json")"
+    status="$(jq -r '.duplicate.status' <<<"$payload_json")"
+    outcome="$(jq -r '.outcome' <<<"$payload_json")"
+    reason="$(jq -r '.reason' <<<"$payload_json")"
+
+    printf '%s [%s] %s\n' "$name" "$(humanize_status "$status")" "$outcome"
+    printf '  %s\n' "$reason"
+
+    if [[ "$(jq -r '.blockers | length' <<<"$payload_json")" != "0" ]]; then
+      while IFS= read -r blocker_json; do
+        local blocker_from_name blocker_from_version blocker_reqs blocker_kinds blocker_targets
+        blocker_from_name="$(jq -r '.from_name' <<<"$blocker_json")"
+        blocker_from_version="$(jq -r '.from_version' <<<"$blocker_json")"
+        blocker_reqs="$(jq -r '.reqs' <<<"$blocker_json")"
+        blocker_kinds="$(jq -r '.kinds' <<<"$blocker_json")"
+        blocker_targets="$(jq -r '.targets' <<<"$blocker_json")"
+        printf '  - blocker: %s %s -> %s [kind=%s target=%s]\n' \
+          "$blocker_from_name" \
+          "$blocker_from_version" \
+          "$blocker_reqs" \
+          "$blocker_kinds" \
+          "$blocker_targets"
+      done < <(jq -c '.blockers[]' <<<"$payload_json")
+    fi
+
+    if [[ "$(jq -r '.suggestions | length' <<<"$payload_json")" == "0" ]]; then
+      echo
+      continue
+    fi
+
+    while IFS= read -r suggestion_json; do
+      local type crate command target_manifest via_manifest via_package from_requirement to_requirement kinds targets
+      type="$(jq -r '.type' <<<"$suggestion_json")"
+
+      case "$type" in
+        cargo-update)
+          command="$(jq -r '.command' <<<"$suggestion_json")"
+          printf '  - run `%s`\n' "$command"
+          ;;
+        workspace-bump|manifest-bump)
+          crate="$(jq -r '.crate' <<<"$suggestion_json")"
+          target_manifest="$(jq -r '.target_manifest' <<<"$suggestion_json")"
+          via_manifest="$(jq -r '.via_manifest' <<<"$suggestion_json")"
+          via_package="$(jq -r '.via_package' <<<"$suggestion_json")"
+          from_requirement="$(jq -r '.from_requirement' <<<"$suggestion_json")"
+          to_requirement="$(jq -r '.to_requirement' <<<"$suggestion_json")"
+          kinds="$(jq -r '.kinds' <<<"$suggestion_json")"
+          targets="$(jq -r '.targets' <<<"$suggestion_json")"
+          printf '  - edit %s: move `%s` from `%s` to `%s` (via %s in %s; kind=%s target=%s)\n' \
+            "$(display_path "$target_manifest")" \
+            "$crate" \
+            "$from_requirement" \
+            "$to_requirement" \
+            "$via_package" \
+            "$(display_path "$via_manifest")" \
+            "$kinds" \
+            "$targets"
+          ;;
+      esac
+    done < <(jq -c '.suggestions[]' <<<"$payload_json")
+
+    echo
+  done
+}
+
+main() {
+  parse_args "$@"
+  ensure_analysis
+
+  case "$MODE" in
+    summary)
+      emit_summary
+      ;;
+    why)
+      emit_why "$(duplicate_record_or_die "$FILTER_CRATE")"
+      ;;
+    suggest)
+      emit_suggest
+      ;;
+  esac
+}
+
+main "$@"
diff --git a/scripts/profile-gitcomet-process-tree.sh b/scripts/profile-gitcomet-process-tree.sh
new file mode 100755
index 00000000..bb834b23
--- /dev/null
+++ b/scripts/profile-gitcomet-process-tree.sh
@@ -0,0 +1,546 @@
+#!/usr/bin/env bash
+set -euo pipefail
+shopt -s nullglob
+
+usage() {
+  cat <<'EOF'
+Usage: scripts/profile-gitcomet-process-tree.sh [options] REPO_PATH [-- extra_gitcomet_args...]
+
+Launch GitComet under a process-tree profiling wrapper that captures:
+  - callgrind outputs for the parent process and traced child processes
+  - strace -ff per-process syscall logs
+  - Git Trace2 event/perf logs for git subprocesses
+
+Options:
+  --binary PATH             GitComet binary to launch.
+                            Default: ./target/release-with-debug/gitcomet
+  --out-dir PATH            Output directory for captured artifacts.
+                            Default: tmp/gitcomet-profiles/
+  --timeout SEC             Terminate the profiled process group after SEC seconds.
+  --manual-instrumentation  Start callgrind with instrumentation disabled.
+                            Turn it on later with callgrind_control.
+  --dry-run                 Print the composed command and output paths without running.
+  -h, --help                Show this help.
+
+Examples:
+  scripts/profile-gitcomet-process-tree.sh /home/sampo/chromium/src
+  scripts/profile-gitcomet-process-tree.sh --timeout 60 /home/sampo/chromium/src
+  scripts/profile-gitcomet-process-tree.sh \
+    --manual-instrumentation \
+    /home/sampo/chromium/src -- --version
+EOF
+}
+
+die() {
+  echo "$*" >&2
+  exit 1
+}
+
+quote_args() {
+  local quoted=""
+  printf -v quoted '%q ' "$@"
+  printf '%s\n' "${quoted% }"
+}
+
+require_tool() {
+  local tool_name="$1"
+  if ! command -v "$tool_name" >/dev/null 2>&1; then
+    die "Required tool not found: ${tool_name}"
+  fi
+}
+
+verify_strace_usable() {
+  local probe_target="/usr/bin/true"
+  if [[ ! -x "$probe_target" ]]; then
+    probe_target="/bin/true"
+  fi
+  if [[ ! -x "$probe_target" ]]; then
+    die "Could not find a probe target for strace usability checks."
+  fi
+
+  local probe_log
+  probe_log="$(mktemp)"
+  local probe_stderr=""
+  if probe_stderr="$(strace -o "$probe_log" "$probe_target" 2>&1 >/dev/null)"; then
+    rm -f "$probe_log"
+    return
+  fi
+  rm -f "$probe_log"
+
+  probe_stderr="$(trim_whitespace "${probe_stderr//$'\n'/ }")"
+  if [[ -z "$probe_stderr" ]]; then
+    probe_stderr="ptrace permission denied or strace is blocked in this environment"
+  fi
+  die "strace cannot trace processes in this environment: ${probe_stderr}"
+}
+
+trim_whitespace() {
+  local value="$1"
+  value="${value#"${value%%[![:space:]]*}"}"
+  value="${value%"${value##*[![:space:]]}"}"
+  printf '%s\n' "$value"
+}
+
+resolve_existing_dir() {
+  local raw="$1"
+  local candidate="$raw"
+  if [[ "$candidate" != /* ]]; then
+    candidate="${orig_cwd}/${candidate}"
+  fi
+  [[ -d "$candidate" ]] || die "Repository path is not a directory: ${raw}"
+  (
+    cd "$candidate"
+    pwd -P
+  )
+}
+
+resolve_path() {
+  local raw="$1"
+  if [[ "$raw" = /* ]]; then
+    printf '%s\n' "$raw"
+  else
+    printf '%s/%s\n' "$orig_cwd" "$raw"
+  fi
+}
+
+host_name() {
+  if command -v hostname >/dev/null 2>&1; then
+    hostname
+  else
+    uname -n
+  fi
+}
+
+append_summary_header() {
+  local title="$1"
+  {
+    printf '\n== %s ==\n' "$title"
+  } >>"$summary_path"
+}
+
+append_execve_summary() {
+  local trace_files=( "$strace_dir"/trace.* )
+  append_summary_header "Strace Execve Summary"
+  if ((${#trace_files[@]} == 0)); then
+    printf 'No strace trace files were captured.\n' >>"$summary_path"
+    return
+  fi
+
+  {
+    printf 'Trace files: %d\n' "${#trace_files[@]}"
+  } >>"$summary_path"
+
+  local emitted=0
+  local trace_file=""
+  local pid=""
+  local line=""
+  local display=""
+  while IFS= read -r trace_file; do
+    pid="${trace_file##*.}"
+    line="$(awk '/execve\(/ { print; exit }' "$trace_file" || true)"
+    if [[ -z "$line" ]]; then
+      continue
+    fi
+    display="$(printf '%s\n' "$line" | sed -E 's/^[^e]*execve\("([^"]+)", \[(.*)\], .*$/\1 [\2]/')"
+    if [[ "$display" == "$line" ]]; then
+      display="$(trim_whitespace "$line")"
+    fi
+    {
+      printf 'PID %s: %s\n' "$pid" "$display"
+    } >>"$summary_path"
+    emitted=$((emitted + 1))
+    if ((emitted >= 80)); then
+      {
+        printf '...truncated after %d processes; inspect %s for full per-PID traces.\n' \
+          "$emitted" "$strace_dir"
+      } >>"$summary_path"
+      return
+    fi
+  done < <(printf '%s\n' "${trace_files[@]}" | sort -t. -k2,2n)
+
+  if ((emitted == 0)); then
+    printf 'No execve records found in strace output.\n' >>"$summary_path"
+  fi
+}
+
+append_trace2_summary() {
+  append_summary_header "Git Trace2 Command Totals"
+
+  local trace_files=( "$trace2_perf_dir"/* )
+  if ((${#trace_files[@]} == 0)); then
+    printf 'No Trace2 perf logs were captured.\n' >>"$summary_path"
+    return
+  fi
+
+  local trace2_rows
+  trace2_rows="$(mktemp)"
+  local trace_file=""
+  local row=""
+  local cmd=""
+  local duration=""
+
+  for trace_file in "${trace_files[@]}"; do
+    [[ -f "$trace_file" ]] || continue
+    row="$(awk -F'\\|' '
+      /\| start[[:space:]]+\|/ && cmd == "" { cmd = $NF }
+      /\| atexit[[:space:]]+\|/ { dur = $6 }
+      /\| exit[[:space:]]+\|/ && dur == "" { dur = $6 }
+      END {
+        gsub(/^[[:space:]]+|[[:space:]]+$/, "", cmd)
+        gsub(/^[[:space:]]+|[[:space:]]+$/, "", dur)
+        if (cmd != "") {
+          printf "%s\t%s\t%s\n", FILENAME, dur, cmd
+        }
+      }
+    ' "$trace_file")"
+    if [[ -n "$row" ]]; then
+      printf '%s\n' "$row" >>"$trace2_rows"
+    fi
+  done
+
+  if [[ ! -s "$trace2_rows" ]]; then
+    printf 'Trace2 perf logs were present, but no command start rows were parsed.\n' >>"$summary_path"
+    rm -f "$trace2_rows"
+    return
+  fi
+
+  {
+    printf 'Raw Trace2 perf directory: %s\n' "$trace2_perf_dir"
+    awk -F'\t' '
+      {
+        duration = $2 + 0
+        command = $3
+        count[command] += 1
+        total[command] += duration
+        if (duration > max[command]) {
+          max[command] = duration
+        }
+      }
+      END {
+        for (command in count) {
+          printf "%.6f\t%d\t%.6f\t%s\n", total[command], count[command], max[command], command
+        }
+      }
+    ' "$trace2_rows" | sort -nr | awk -F'\t' '
+      BEGIN { emitted = 0 }
+      {
+        printf "count=%d total=%.3fs max=%.3fs %s\n", $2, $1, $3, $4
+        emitted += 1
+        if (emitted >= 20) {
+          exit
+        }
+      }
+    '
+  } >>"$summary_path"
+
+  rm -f "$trace2_rows"
+}
+
+append_callgrind_summary() {
+  append_summary_header "Callgrind Hotspots"
+
+  local callgrind_files=( "$callgrind_dir"/callgrind.out.* )
+  if ((${#callgrind_files[@]} == 0)); then
+    printf 'No callgrind outputs were captured.\n' >>"$summary_path"
+    return
+  fi
+
+  local callgrind_file=""
+  local pid=""
+  local annotation_file=""
+  local hotspot=""
+  for callgrind_file in "${callgrind_files[@]}"; do
+    [[ -f "$callgrind_file" ]] || continue
+    pid="${callgrind_file##*.}"
+    annotation_file="${callgrind_annotation_dir}/$(basename "$callgrind_file").annotate.txt"
+    if callgrind_annotate --auto=yes --inclusive=yes --threshold=95 "$callgrind_file" >"$annotation_file" 2>&1; then
+      hotspot="$(awk '
+        /^Ir[[:space:]]+file:function$/ { in_hotspots = 1; next }
+        in_hotspots && $1 ~ /^[0-9,]+$/ { print; exit }
+      ' "$annotation_file")"
+      if [[ -z "$hotspot" ]]; then
+        hotspot="(no hotspot line parsed)"
+      fi
+    else
+      hotspot="(callgrind_annotate failed; inspect ${annotation_file})"
+    fi
+    {
+      printf 'PID %s: %s\n' "$pid" "$hotspot"
+      printf '  annotation: %s\n' "$annotation_file"
+    } >>"$summary_path"
+  done
+}
+
+write_metadata() {
+  {
+    printf 'created_utc: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+    printf 'hostname: %s\n' "$(host_name)"
+    printf 'repo_root: %s\n' "$repo_root"
+    printf 'repo_path: %s\n' "$repo_path"
+    printf 'binary: %s\n' "$binary_path"
+    printf 'out_dir: %s\n' "$out_dir"
+    printf 'stdout_log: %s\n' "$stdout_log"
+    printf 'stderr_log: %s\n' "$stderr_log"
+    printf 'strace_dir: %s\n' "$strace_dir"
+    printf 'callgrind_dir: %s\n' "$callgrind_dir"
+    printf 'trace2_event_dir: %s\n' "$trace2_event_dir"
+    printf 'trace2_perf_dir: %s\n' "$trace2_perf_dir"
+    printf 'manual_instrumentation: %s\n' "$manual_instrumentation"
+    printf 'timeout_seconds: %s\n' "${timeout_secs:-none}"
+    printf 'command: %s\n' "$(quote_args "${profile_command[@]}")"
+  } >"$metadata_path"
+}
+
+finalize_metadata() {
+  {
+    printf 'root_pid: %s\n' "$root_pid"
+    printf 'completed_utc: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+    printf 'raw_exit_status: %s\n' "$run_status"
+    printf 'timed_out: %s\n' "$timed_out"
+    printf 'script_exit_status: %s\n' "$script_exit_status"
+  } >>"$metadata_path"
+}
+
+write_summary() {
+  {
+    printf 'GitComet process-tree profile bundle\n'
+    printf 'Repository: %s\n' "$repo_path"
+    printf 'Binary: %s\n' "$binary_path"
+    printf 'Output: %s\n' "$out_dir"
+    printf 'Root PID: %s\n' "$root_pid"
+    printf 'Raw exit status: %s\n' "$run_status"
+    printf 'Timed out: %s\n' "$timed_out"
+    if ((manual_instrumentation == 1)); then
+      printf 'Manual instrumentation: enabled\n'
+      printf 'Callgrind control hint: callgrind_control -i on\n'
+      printf 'Callgrind control hint: callgrind_control -i off\n'
+    fi
+  } >"$summary_path"
+
+  append_execve_summary
+  append_trace2_summary
+  append_callgrind_summary
+}
+
+cleanup_on_exit() {
+  local trap_status=$?
+  if [[ -n "${watchdog_pid:-}" ]]; then
+    kill "$watchdog_pid" 2>/dev/null || true
+    wait "$watchdog_pid" 2>/dev/null || true
+  fi
+  if [[ -n "${runner_pid:-}" ]] && kill -0 "$runner_pid" 2>/dev/null; then
+    kill -TERM -- "-${runner_pgid:-$runner_pid}" 2>/dev/null || true
+    sleep 1
+    kill -KILL -- "-${runner_pgid:-$runner_pid}" 2>/dev/null || true
+  fi
+  return "$trap_status"
+}
+
+orig_cwd="$PWD"
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
+
+binary_raw=""
+out_dir_raw=""
+timeout_secs=""
+manual_instrumentation=0
+dry_run=0
+repo_arg=""
+extra_gitcomet_args=()
+
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --binary)
+      [[ $# -ge 2 ]] || die "Missing value for --binary"
+      binary_raw="$2"
+      shift 2
+      ;;
+    --out-dir)
+      [[ $# -ge 2 ]] || die "Missing value for --out-dir"
+      out_dir_raw="$2"
+      shift 2
+      ;;
+    --timeout)
+      [[ $# -ge 2 ]] || die "Missing value for --timeout"
+      timeout_secs="$2"
+      shift 2
+      ;;
+    --manual-instrumentation)
+      manual_instrumentation=1
+      shift
+      ;;
+    --dry-run)
+      dry_run=1
+      shift
+      ;;
+    --)
+      shift
+      extra_gitcomet_args+=("$@")
+      break
+      ;;
+    -h|--help)
+      usage
+      exit 0
+      ;;
+    -*)
+      die "Unknown option: $1"
+      ;;
+    *)
+      if [[ -n "$repo_arg" ]]; then
+        die "Unexpected extra positional argument: $1"
+      fi
+      repo_arg="$1"
+      shift
+      ;;
+  esac
+done
+
+[[ -n "$repo_arg" ]] || {
+  usage >&2
+  exit 2
+}
+
+if [[ -n "$timeout_secs" ]] && [[ ! "$timeout_secs" =~ ^[0-9]+$ ]]; then
+  die "--timeout expects a whole number of seconds"
+fi
+
+repo_path="$(resolve_existing_dir "$repo_arg")"
+binary_path="${repo_root}/target/release-with-debug/gitcomet"
+if [[ -n "$binary_raw" ]]; then
+  binary_path="$(resolve_path "$binary_raw")"
+fi
+
+timestamp="$(date -u +%Y%m%d-%H%M%SZ)"
+out_dir="${repo_root}/tmp/gitcomet-profiles/${timestamp}"
+if [[ -n "$out_dir_raw" ]]; then
+  out_dir="$(resolve_path "$out_dir_raw")"
+fi
+
+stdout_log="${out_dir}/stdout.log"
+stderr_log="${out_dir}/stderr.log"
+summary_path="${out_dir}/summary.txt"
+metadata_path="${out_dir}/metadata.txt"
+timed_out_flag="${out_dir}/timed_out.flag"
+strace_dir="${out_dir}/strace"
+strace_prefix="${strace_dir}/trace"
+callgrind_dir="${out_dir}/callgrind"
+callgrind_annotation_dir="${callgrind_dir}/annotations"
+trace2_event_dir="${out_dir}/trace2-event"
+trace2_perf_dir="${out_dir}/trace2-perf"
+
+profile_command=(
+  env
+  "GIT_TRACE2_EVENT=${trace2_event_dir}"
+  "GIT_TRACE2_PERF=${trace2_perf_dir}"
+  strace
+  -ff
+  -ttT
+  -o
+  "$strace_prefix"
+  valgrind
+  --tool=callgrind
+  --trace-children=yes
+  --callgrind-out-file="${callgrind_dir}/callgrind.out.%p"
+  --dump-instr=yes
+  --collect-jumps=yes
+  --sigill-diagnostics=no
+  --error-limit=no
+)
+
+if ((manual_instrumentation == 1)); then
+  profile_command+=(--instr-atstart=no)
+fi
+
+profile_command+=("$binary_path" "$repo_path")
+if ((${#extra_gitcomet_args[@]} > 0)); then
+  profile_command+=("${extra_gitcomet_args[@]}")
+fi
+
+if ((dry_run == 1)); then
+  {
+    printf 'Output directory: %s\n' "$out_dir"
+    printf 'Profile command: %s\n' "$(quote_args "${profile_command[@]}")"
+  }
+  exit 0
+fi
+
+[[ ! -e "$out_dir" ]] || die "Output directory already exists: ${out_dir}"
+
+require_tool git
+require_tool setsid
+require_tool strace
+require_tool valgrind
+require_tool callgrind_annotate
+if ((manual_instrumentation == 1)); then
+  require_tool callgrind_control
+fi
+
+if [[ ! -x "$binary_path" ]]; then
+  die "GitComet binary not found or not executable at ${binary_path}. Build it with: bash scripts/build_release_debug.sh"
+fi
+verify_strace_usable
+
+mkdir -p \
+  "$out_dir" \
+  "$strace_dir" \
+  "$callgrind_dir" \
+  "$callgrind_annotation_dir" \
+  "$trace2_event_dir" \
+  "$trace2_perf_dir"
+
+runner_pid=""
+runner_pgid=""
+watchdog_pid=""
+root_pid=""
+run_status=0
+timed_out=0
+script_exit_status=0
+
+trap cleanup_on_exit EXIT
+
+write_metadata
+
+setsid "${profile_command[@]}" >"$stdout_log" 2>"$stderr_log" &
+runner_pid="$!"
+runner_pgid="$runner_pid"
+root_pid="$runner_pid"
+
+if [[ -n "$timeout_secs" ]]; then
+  (
+    sleep "$timeout_secs"
+    if kill -0 "$runner_pid" 2>/dev/null; then
+      printf 'Timed out after %s seconds\n' "$timeout_secs" >"$timed_out_flag"
+      kill -TERM -- "-$runner_pgid" 2>/dev/null || true
+      sleep 5
+      kill -KILL -- "-$runner_pgid" 2>/dev/null || true
+    fi
+  ) &
+  watchdog_pid="$!"
+fi
+
+set +e
+wait "$runner_pid"
+run_status=$?
+set -e
+
+if [[ -n "$watchdog_pid" ]]; then
+  kill "$watchdog_pid" 2>/dev/null || true
+  wait "$watchdog_pid" 2>/dev/null || true
+  watchdog_pid=""
+fi
+
+runner_pid=""
+runner_pgid=""
+
+if [[ -f "$timed_out_flag" ]]; then
+  timed_out=1
+fi
+
+script_exit_status="$run_status"
+if ((timed_out == 1)); then
+  script_exit_status=124
+fi
+
+finalize_metadata
+write_summary
+
+exit "$script_exit_status"