diff --git a/.github/workflows/build-bundles.yml b/.github/workflows/build-bundles.yml index a69ff09..c1e92cb 100644 --- a/.github/workflows/build-bundles.yml +++ b/.github/workflows/build-bundles.yml @@ -1068,3 +1068,53 @@ jobs: --header "x-ci-signature: sha256=$SIGNATURE" \ --data "$PAYLOAD" \ "${WORKER_URL%/}/webhook" + + notify-website-release-metadata: + name: Notify website release metadata + if: ${{ github.event_name == 'release' }} + needs: + - linux-bundles + - windows-bundles + - update-updater-json + - windows-msix + - macos-bundles + - notify-r2-release-sync + runs-on: ubuntu-22.04 + + steps: + - name: Dispatch website metadata update + env: + DISPATCH_TOKEN: ${{ secrets.GITMUN_WEBSITE_DISPATCH_TOKEN }} + GITHUB_API_URL: ${{ github.api_url }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_URL: ${{ github.event.release.html_url }} + shell: bash + run: | + set -euo pipefail + + if [ -z "${DISPATCH_TOKEN:-}" ]; then + echo "GITMUN_WEBSITE_DISPATCH_TOKEN is not configured." >&2 + exit 1 + fi + + payload="$( + jq -cn \ + --arg event_type "gitmun-release-assets-ready" \ + --arg tag "$RELEASE_TAG" \ + --arg release_url "$RELEASE_URL" \ + '{ + event_type: $event_type, + client_payload: { + tag: $tag, + release_url: $release_url + } + }' + )" + + curl --fail-with-body \ + --request POST \ + --header "Accept: application/vnd.github+json" \ + --header "Authorization: Bearer ${DISPATCH_TOKEN}" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + --data "$payload" \ + "${GITHUB_API_URL%/}/repos/cst8t/gitmun-org-website/dispatches" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 0dd2f9d..074ff14 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -25,7 +25,11 @@ { "identifier": "opener:allow-open-path", "allow": [ - { "path": "$APPCONFIG" } + { "path": "$APPCONFIG" }, + { "path": "$HOME/**" }, + { "path": "$DESKTOP/**" }, + { "path": "$DOCUMENT/**" }, + { "path": "$DOWNLOAD/**" } ] } ] diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 65bac4f..5de1780 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default capability for invoking app commands","local":true,"windows":["main","settings","clone-repository","result-log","about","attributions"],"permissions":["core:default","core:webview:allow-create-webview-window","core:window:allow-create","core:window:allow-set-focus","core:window:allow-set-title","core:window:allow-show","core:window:allow-close","dialog:allow-open","dialog:allow-ask","os:default","core:menu:allow-popup","core:menu:allow-new","core:menu:allow-append","core:menu:allow-remove","core:window:allow-cursor-position","core:window:allow-outer-position","updater:default","shell:allow-open",{"identifier":"opener:allow-open-path","allow":[{"path":"$APPCONFIG"}]}]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capability for invoking app commands","local":true,"windows":["main","settings","clone-repository","result-log","about","attributions"],"permissions":["core:default","core:webview:allow-create-webview-window","core:window:allow-create","core:window:allow-set-focus","core:window:allow-set-title","core:window:allow-show","core:window:allow-close","dialog:allow-open","dialog:allow-ask","os:default","core:menu:allow-popup","core:menu:allow-new","core:menu:allow-append","core:menu:allow-remove","core:window:allow-cursor-position","core:window:allow-outer-position","updater:default","shell:allow-open",{"identifier":"opener:allow-open-path","allow":[{"path":"$APPCONFIG"},{"path":"$HOME/**"},{"path":"$DESKTOP/**"},{"path":"$DOCUMENT/**"},{"path":"$DOWNLOAD/**"}]}]}} \ No newline at end of file diff --git a/src-tauri/src/commands/repo.rs b/src-tauri/src/commands/repo.rs index bdf7e9f..53caae1 100644 --- a/src-tauri/src/commands/repo.rs +++ b/src-tauri/src/commands/repo.rs @@ -7,10 +7,282 @@ use crate::git::types::{ SubmoduleActionRequest, }; use crate::{AppState, CloneCancelFlag, configure_command}; +use serde::{Deserialize, Serialize}; use std::io::Read; -use std::process::Stdio; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; use std::sync::atomic::Ordering; use tauri::Manager; +use tauri_plugin_opener::OpenerExt; + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum RepoOpenLocationKind { + FileExplorer, + Terminal, + GitBash, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RepoOpenLocation { + kind: RepoOpenLocationKind, + label: String, + fallback_label: String, + icon_data_url: Option, +} + +#[tauri::command] +pub fn get_repo_open_locations() -> Vec { + let locations = vec![ + RepoOpenLocation { + kind: RepoOpenLocationKind::FileExplorer, + label: default_file_manager_label().to_string(), + fallback_label: default_file_manager_label().to_string(), + icon_data_url: None, + }, + RepoOpenLocation { + kind: RepoOpenLocationKind::Terminal, + label: default_terminal_label().to_string(), + fallback_label: "Terminal".to_string(), + icon_data_url: None, + }, + ]; + + #[cfg(target_os = "windows")] + let locations = { + let mut locations = locations; + if crate::resolve_system_git_bash_exe().is_some() { + locations.push(RepoOpenLocation { + kind: RepoOpenLocationKind::GitBash, + label: "Git Bash".to_string(), + fallback_label: "Git Bash".to_string(), + icon_data_url: None, + }); + } + locations + }; + + locations +} + +#[tauri::command] +pub fn open_repo_location( + repo_path: String, + kind: RepoOpenLocationKind, + app: tauri::AppHandle, +) -> Result { + let path = validate_repo_open_path(&repo_path)?; + + match kind { + RepoOpenLocationKind::FileExplorer => { + app.opener() + .open_path(path.to_string_lossy().to_string(), None::<&str>) + .map_err(|e| format!("Failed to open file manager: {e}"))?; + Ok(repo_open_result( + format!("Opened repository in {}", default_file_manager_label()), + path, + )) + } + RepoOpenLocationKind::Terminal => { + open_terminal_at(&path)?; + Ok(repo_open_result( + "Opened repository in Terminal".to_string(), + path, + )) + } + RepoOpenLocationKind::GitBash => { + open_git_bash_at(&path)?; + Ok(repo_open_result( + "Opened repository in Git Bash".to_string(), + path, + )) + } + } +} + +fn validate_repo_open_path(repo_path: &str) -> Result { + let trimmed = repo_path.trim(); + if trimmed.is_empty() { + return Err("Repository path cannot be empty".to_string()); + } + + let path = PathBuf::from(trimmed); + if !path.is_dir() { + return Err("Repository path must be an existing directory".to_string()); + } + Ok(path) +} + +fn repo_open_result(message: String, path: PathBuf) -> OperationResult { + OperationResult { + message, + output: None, + repo_path: Some(path.to_string_lossy().to_string()), + backend_used: "git-cli".to_string(), + interpreted_error: None, + } +} + +fn default_file_manager_label() -> &'static str { + #[cfg(target_os = "windows")] + { + "File Explorer" + } + #[cfg(target_os = "macos")] + { + "Finder" + } + #[cfg(target_os = "linux")] + { + "File Manager" + } + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + { + "File Manager" + } +} + +fn default_terminal_label() -> &'static str { + "Terminal" +} + +fn open_terminal_at(path: &Path) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + return open_terminal_at_windows(path); + } + + #[cfg(target_os = "macos")] + { + return open_terminal_at_macos(path); + } + + #[cfg(target_os = "linux")] + { + return open_terminal_at_linux(path); + } + + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + { + let _ = path; + Err("Opening a terminal is not supported on this platform".to_string()) + } +} + +fn open_git_bash_at(path: &Path) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + return open_git_bash_at_windows(path); + } + + #[cfg(not(target_os = "windows"))] + { + let _ = path; + Err("Git Bash is only available on Windows".to_string()) + } +} + +#[cfg(target_os = "windows")] +fn open_git_bash_at_windows(path: &Path) -> Result<(), String> { + let git_bash = crate::resolve_system_git_bash_exe() + .ok_or_else(|| "Git Bash from Git for Windows was not found".to_string())?; + Command::new(git_bash) + .arg(format!("--cd={}", path.display())) + .spawn() + .map(|_| ()) + .map_err(|e| format!("Failed to open Git Bash: {e}")) +} + +#[cfg(target_os = "windows")] +fn open_terminal_at_windows(path: &Path) -> Result<(), String> { + let mut wt = Command::new("wt.exe"); + wt.arg("-d").arg(path); + if wt.spawn().is_ok() { + return Ok(()); + } + + Command::new("cmd.exe") + .arg("/C") + .arg("start") + .arg("") + .arg("/D") + .arg(path) + .arg("cmd.exe") + .spawn() + .map(|_| ()) + .map_err(|e| format!("Failed to open terminal: {e}")) +} + +#[cfg(target_os = "macos")] +fn open_terminal_at_macos(path: &Path) -> Result<(), String> { + Command::new("open") + .arg("-a") + .arg("Terminal") + .arg(path) + .spawn() + .map(|_| ()) + .map_err(|e| format!("Failed to open Terminal: {e}")) +} + +#[cfg(target_os = "linux")] +fn open_terminal_at_linux(path: &Path) -> Result<(), String> { + let mut errors = Vec::new(); + + if let Some(terminal) = std::env::var_os("TERMINAL").and_then(|value| value.into_string().ok()) + { + if !terminal.trim().is_empty() && spawn_terminal_command(&terminal, path, &mut errors) { + return Ok(()); + } + } + + for command in [ + "x-terminal-emulator", + "gnome-terminal", + "kgx", + "konsole", + "xfce4-terminal", + "mate-terminal", + "lxterminal", + "alacritty", + "kitty", + "wezterm", + "foot", + "xterm", + ] { + if spawn_terminal_command(command, path, &mut errors) { + return Ok(()); + } + } + + Err(if errors.is_empty() { + "No supported terminal emulator was found".to_string() + } else { + format!( + "No supported terminal emulator was found ({})", + errors.join("; ") + ) + }) +} + +#[cfg(target_os = "linux")] +fn spawn_terminal_command(command_spec: &str, path: &Path, errors: &mut Vec) -> bool { + let mut parts = command_spec.split_whitespace(); + let Some(command_name) = parts.next() else { + return false; + }; + + let mut command = Command::new(command_name); + command.args(parts).current_dir(path); + + match command.spawn() { + Ok(_) => true, + Err(error) => { + errors.push(format!("{command_name}: {error}")); + false + } + } +} #[tauri::command] pub async fn get_commit_markers( @@ -83,6 +355,7 @@ pub fn init_repo(repo_path: String) -> Result { output: None, repo_path: Some(path.to_string_lossy().to_string()), backend_used: "git-cli".to_string(), + interpreted_error: None, }); } @@ -117,6 +390,7 @@ pub fn init_repo(repo_path: String) -> Result { output: None, repo_path: Some(path.to_string_lossy().to_string()), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -232,6 +506,7 @@ pub async fn clone_repo( output: None, repo_path: Some(final_dest_str), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 2841b69..1ba961b 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -769,6 +769,7 @@ pub fn set_global_diff_tool( output: None, repo_path: None, backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -790,6 +791,7 @@ pub fn set_global_default_branch(default_branch: String) -> Result Result output: None, repo_path: None, backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -955,6 +959,7 @@ pub fn set_global_gpg_program(gpg_program: String) -> Result PushRejectionAnalysis { - let stderr_lower = stderr.to_ascii_lowercase(); let (current_branch, upstream_branch) = Self::push_failure_branch_context(repo_path); - - let (kind, message, suggested_next_actions) = if stderr_lower.contains("non-fast-forward") - || stderr_lower.contains("[rejected]") - || stderr_lower.contains("fetch first") - || stderr_lower.contains("tip of your current branch is behind") - { - ( - PushFailureKind::NonFastForward, - "Push was rejected because the remote branch has new commits. Fetch, review, and integrate those changes before pushing again.".to_string(), - vec![ - "fetch".to_string(), - "review".to_string(), - "integrate".to_string(), - ], - ) - } else if stderr_lower.contains("no upstream branch") { - ( - PushFailureKind::NoUpstream, - "This branch does not have an upstream yet. Publish it to a remote before pushing normally.".to_string(), - vec!["publish".to_string()], - ) - } else if stderr_lower.contains("upstream branch of your current branch does not match") - || stderr_lower.contains("has no such ref was fetched") - || stderr_lower.contains("couldn't find remote ref") - || stderr_lower.contains("upstream is gone") - || stderr_lower.contains("remote branch") - && (stderr_lower.contains("not found") - || stderr_lower.contains("does not exist") - || stderr_lower.contains("missing")) - { - ( - PushFailureKind::UpstreamMissing, - "The configured upstream branch is missing or no longer matches this branch. Repair the upstream before retrying.".to_string(), - vec!["repair-upstream".to_string()], - ) - } else if stderr_lower.contains("authentication failed") - || stderr_lower.contains("could not read from remote repository") - || stderr_lower.contains("permission denied") - || stderr_lower.contains("permission to") - || stderr_lower.contains("repository not found") - { - ( - PushFailureKind::Auth, - "Push failed because Git could not authenticate with the remote.".to_string(), - vec!["retry".to_string()], - ) - } else if stderr_lower.contains("could not resolve host") - || stderr_lower.contains("failed to connect") - || stderr_lower.contains("connection timed out") - || stderr_lower.contains("network is unreachable") - || stderr_lower.contains("connection reset") - { - ( - PushFailureKind::Network, - "Push failed because Git could not reach the remote.".to_string(), - vec!["retry".to_string()], - ) + let interpreted = Self::interpret_push_failure(stderr, None); + let kind = Self::push_failure_kind(interpreted.category); + let suggested_next_actions = if matches!(kind, PushFailureKind::NoUpstream) { + vec!["publish".to_string()] } else { - ( - PushFailureKind::Other, - "Push failed. Review the Git output and retry when the repository state is clear." - .to_string(), - vec!["retry".to_string()], - ) + interpreted.suggested_actions }; PushRejectionAnalysis { @@ -901,11 +847,26 @@ impl CliGitHandler { current_branch, upstream_branch, kind, - message, + message: interpreted.summary, suggested_next_actions, } } + fn interpret_push_failure(stderr: &str, exit_code: Option) -> InterpretedGitError { + interpret_cli_error(Some("push"), stderr, exit_code) + } + + fn push_failure_kind(category: GitErrorCategory) -> PushFailureKind { + match category { + GitErrorCategory::NonFastForward => PushFailureKind::NonFastForward, + GitErrorCategory::NoUpstream => PushFailureKind::NoUpstream, + GitErrorCategory::UpstreamMissing => PushFailureKind::UpstreamMissing, + GitErrorCategory::Auth => PushFailureKind::Auth, + GitErrorCategory::Network => PushFailureKind::Network, + _ => PushFailureKind::Other, + } + } + fn execute_pull_command( &self, repo_path: &Path, @@ -919,6 +880,7 @@ impl CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }), Err(GitError::CommandFailed { stderr, .. }) => { let status = self.get_repo_status(&Self::repo_request(repo_path))?; @@ -928,12 +890,14 @@ impl CliGitHandler { output: (!stderr.is_empty()).then_some(stderr), repo_path: Some(Self::path_to_string(repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }); } Err(GitError::CommandFailed { command: format!("git {}", args.join(" ")), stderr, + exit_code: None, }) } Err(error) => Err(error), @@ -1177,6 +1141,7 @@ impl CliGitHandler { return Err(GitError::CommandFailed { command: joined, stderr, + exit_code: output.status.code(), }); } @@ -1217,6 +1182,7 @@ impl CliGitHandler { return Err(GitError::CommandFailed { command: joined, stderr, + exit_code: output.status.code(), }); } @@ -1382,6 +1348,7 @@ impl CliGitHandler { return Err(GitError::CommandFailed { command: format!("open {}", path.display()), stderr, + exit_code: output.status.code(), }); } @@ -1648,6 +1615,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&resolved_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -1689,6 +1657,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(final_destination_str), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -1815,6 +1784,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -1842,6 +1812,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -1876,6 +1847,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }); } @@ -1904,6 +1876,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -1967,6 +1940,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2079,9 +2053,10 @@ impl GitOperationHandler for CliGitHandler { let output = match Self::run_git(args.as_slice(), Some(&repo_path)) { Ok(stdout) => stdout, - Err(GitError::CommandFailed { command: _, stderr }) - if stderr.contains("does not have any commits yet") - || stderr.contains("appears to be broken") => + Err(GitError::CommandFailed { + command: _, stderr, .. + }) if stderr.contains("does not have any commits yet") + || stderr.contains("appears to be broken") => { return Ok(Vec::new()); } @@ -2287,7 +2262,9 @@ impl GitOperationHandler for CliGitHandler { let output = match Self::run_git(&args, Some(&repo_path)) { Ok(stdout) => stdout, - Err(GitError::CommandFailed { command: _, stderr }) if stderr.is_empty() => { + Err(GitError::CommandFailed { + command: _, stderr, .. + }) if stderr.is_empty() => { // Empty stderr + non-zero exit: no tracked changes. Fall through to check // whether this is an untracked new file that we can diff with --no-index. String::new() @@ -2449,6 +2426,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2465,6 +2443,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2477,6 +2456,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2517,6 +2497,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2553,6 +2534,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2604,6 +2586,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2749,6 +2732,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2779,6 +2763,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2831,6 +2816,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2843,6 +2829,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2855,6 +2842,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2965,6 +2953,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -2980,7 +2969,9 @@ impl GitOperationHandler for CliGitHandler { Some(&repo_path), ) { Ok(stdout) => stdout, - Err(GitError::CommandFailed { command: _, stderr }) if stderr.is_empty() => { + Err(GitError::CommandFailed { + command: _, stderr, .. + }) if stderr.is_empty() => { return Ok(Vec::new()); } Err(error) => return Err(error), @@ -3120,16 +3111,21 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, success: true, rejection: None, }), - Err(GitError::CommandFailed { stderr, .. }) => { + Err(GitError::CommandFailed { + stderr, exit_code, .. + }) => { + let interpreted = Self::interpret_push_failure(&stderr, exit_code); let rejection = Self::classify_push_failure(&repo_path, &stderr); Ok(PushResult { message: rejection.message.clone(), output: (!stderr.is_empty()).then_some(stderr), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: Some(interpreted), success: false, rejection: Some(rejection), }) @@ -3161,6 +3157,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3213,6 +3210,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3298,6 +3296,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3325,6 +3324,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3388,6 +3388,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3404,6 +3405,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3459,6 +3461,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3477,6 +3480,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3495,6 +3499,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3532,6 +3537,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3552,6 +3558,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3565,6 +3572,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3580,6 +3588,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3600,6 +3609,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3613,6 +3623,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3675,6 +3686,7 @@ impl GitOperationHandler for CliGitHandler { output: Some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, success: !has_conflicts, has_conflicts, conflicted_files, @@ -3705,6 +3717,7 @@ impl GitOperationHandler for CliGitHandler { }, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3741,9 +3754,11 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, success: !has_conflicts, has_conflicts, conflicted_files, + rebase_in_progress, }) } @@ -3783,9 +3798,11 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, success: !has_conflicts, has_conflicts, conflicted_files, + rebase_in_progress, }) } @@ -3803,6 +3820,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3840,6 +3858,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, success: !has_conflicts, has_conflicts, conflicted_files, @@ -3882,6 +3901,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, success: !has_conflicts, has_conflicts, conflicted_files, @@ -3902,6 +3922,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -3941,6 +3962,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, success: !has_conflicts, has_conflicts, conflicted_files, @@ -3983,6 +4005,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, success: !has_conflicts, has_conflicts, conflicted_files, @@ -4003,6 +4026,7 @@ impl GitOperationHandler for CliGitHandler { output: (!output.is_empty()).then_some(output), repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -4030,6 +4054,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -4048,6 +4073,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -4066,6 +4092,7 @@ impl GitOperationHandler for CliGitHandler { output: None, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } @@ -4111,6 +4138,7 @@ impl GitOperationHandler for CliGitHandler { }, repo_path: Some(Self::path_to_string(&repo_path)), backend_used: "git-cli".to_string(), + interpreted_error: None, }) } } diff --git a/src-tauri/src/git/error.rs b/src-tauri/src/git/error.rs index 0a033d1..eb98f28 100644 --- a/src-tauri/src/git/error.rs +++ b/src-tauri/src/git/error.rs @@ -1,12 +1,21 @@ use std::fmt::{Display, Formatter}; +use super::error_interpretation::{interpret_cli_error, interpret_gix_error, InterpretedGitError}; + #[derive(Debug)] pub enum GitError { InvalidInput(String), GitUnavailable, - CommandFailed { command: String, stderr: String }, + CommandFailed { + command: String, + stderr: String, + exit_code: Option, + }, IoError(String), - GixError(String), + GixError { + message: String, + interpreted: Option, + }, } impl Display for GitError { @@ -14,15 +23,39 @@ impl Display for GitError { match self { Self::InvalidInput(message) => write!(f, "Invalid input: {message}"), Self::GitUnavailable => write!(f, "Git executable was not found on PATH"), - Self::CommandFailed { command, stderr } => { + Self::CommandFailed { + command, + stderr, + exit_code, + } => { + let operation = command.split_whitespace().nth(1); + let interpreted = interpret_cli_error(operation, stderr, *exit_code); if stderr.is_empty() { - write!(f, "Git command failed: {command}") + write!(f, "{}\nGit command failed: {command}", interpreted.summary) } else { - write!(f, "Git command failed: {command}\n{stderr}") + write!( + f, + "{}\nGit command failed: {command}\n{stderr}", + interpreted.summary + ) } } Self::IoError(message) => write!(f, "I/O error: {message}"), - Self::GixError(message) => write!(f, "gix error: {message}"), + Self::GixError { + message, + interpreted, + } => { + let fallback; + let interpreted = match interpreted { + Some(interpreted) => interpreted, + None => { + fallback = + interpret_gix_error(None, &std::io::Error::other(message.clone())); + &fallback + } + }; + write!(f, "{}\ngix error: {message}", interpreted.summary) + } } } } diff --git a/src-tauri/src/git/error_interpretation.rs b/src-tauri/src/git/error_interpretation.rs new file mode 100644 index 0000000..096d23d --- /dev/null +++ b/src-tauri/src/git/error_interpretation.rs @@ -0,0 +1,574 @@ +use std::any::type_name; +use std::error::Error; + +use serde::Serialize; + +/// Turns Git errors into short explanations and next-step action IDs. +/// +/// When adding a new case, put the clearest match before looser matches, choose +/// the closest shared category, and add any new action IDs in `advice_for`. +/// Use higher confidence for exact Git messages, medium confidence for broad +/// gix error families, and low confidence for plain text guesses. Unknown +/// errors should stay as `Other` and keep the raw Git message. +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum GitErrorCategory { + Auth, + Network, + NonFastForward, + NoUpstream, + UpstreamMissing, + ConflictInProgress, + IndexLock, + RepoState, + InvalidInput, + ToolUnavailable, + Permission, + Other, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum GitBackendSource { + GitCli, + Gix, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InterpretedGitError { + pub category: GitErrorCategory, + pub summary: String, + pub suggested_actions: Vec, + pub confidence: f32, + pub backend: GitBackendSource, + pub raw_message: String, + pub operation: Option, +} + +#[derive(Debug, Clone, Copy)] +struct Advice { + summary: &'static str, + actions: &'static [&'static str], +} + +fn advice_for(category: GitErrorCategory, operation: Option<&str>) -> Advice { + match (category, operation) { + (GitErrorCategory::NonFastForward, Some("push")) => Advice { + summary: "Push was rejected because the remote branch has new commits.", + actions: &["fetch", "review", "integrate"], + }, + (GitErrorCategory::NonFastForward, _) => Advice { + summary: "The remote branch has new commits that are not present locally.", + actions: &["fetch", "review", "integrate"], + }, + (GitErrorCategory::NoUpstream, Some("push")) => Advice { + summary: "This branch does not have an upstream yet.", + actions: &["set-upstream"], + }, + (GitErrorCategory::NoUpstream, _) => Advice { + summary: "This branch does not have an upstream configured.", + actions: &["set-upstream"], + }, + (GitErrorCategory::UpstreamMissing, _) => Advice { + summary: "The configured upstream branch is missing or no longer matches this branch.", + actions: &["fetch", "repair-upstream"], + }, + (GitErrorCategory::Auth, _) => Advice { + summary: "Git could not authenticate with the remote.", + actions: &["fix-auth-ssh", "fix-auth-https", "retry"], + }, + (GitErrorCategory::Network, _) => Advice { + summary: "Git could not reach the remote.", + actions: &["check-network", "retry"], + }, + (GitErrorCategory::ConflictInProgress, _) => Advice { + summary: "A merge, rebase, cherry-pick, or revert needs attention first.", + actions: &["resolve-conflicts", "continue-sequencer", "abort-sequencer"], + }, + (GitErrorCategory::IndexLock, _) => Advice { + summary: "Git could not lock the repository index.", + actions: &["unlock-index", "retry"], + }, + (GitErrorCategory::RepoState, _) => Advice { + summary: "The repository state blocks this operation.", + actions: &["review"], + }, + (GitErrorCategory::InvalidInput, _) => Advice { + summary: "Git rejected the supplied input.", + actions: &["review"], + }, + (GitErrorCategory::ToolUnavailable, _) => Advice { + summary: "The Git executable or a required Git tool is unavailable.", + actions: &["open-settings-git-executable"], + }, + (GitErrorCategory::Permission, _) => Advice { + summary: "Git does not have permission to read or write a required path.", + actions: &["review", "retry"], + }, + (GitErrorCategory::Other, _) => Advice { + summary: "Git failed before the operation could complete.", + actions: &["review", "retry"], + }, + } +} + +fn build_interpretation( + category: GitErrorCategory, + backend: GitBackendSource, + operation: Option<&str>, + raw_message: &str, + confidence: f32, +) -> InterpretedGitError { + let advice = advice_for(category, operation); + InterpretedGitError { + category, + summary: advice.summary.to_string(), + suggested_actions: advice + .actions + .iter() + .map(|action| action.to_string()) + .collect(), + confidence: confidence.clamp(0.0, 1.0), + backend, + raw_message: raw_message.to_string(), + operation: operation.map(str::to_string), + } +} + +fn contains_any(haystack: &str, needles: &[&str]) -> bool { + needles.iter().any(|needle| haystack.contains(needle)) +} + +fn classify_message(message: &str) -> (GitErrorCategory, f32) { + let lower = message.to_ascii_lowercase(); + + if contains_any( + &lower, + &[ + "non-fast-forward", + "fetch first", + "tip of your current branch is behind", + ], + ) || (lower.contains("[rejected]") && lower.contains("push")) + { + return (GitErrorCategory::NonFastForward, 0.95); + } + + if lower.contains("no upstream branch") { + return (GitErrorCategory::NoUpstream, 0.95); + } + + if contains_any( + &lower, + &[ + "upstream branch of your current branch does not match", + "has no such ref was fetched", + "couldn't find remote ref", + "could not find remote ref", + "upstream is gone", + "remote ref does not exist", + "no such ref", + "no such remote ref", + ], + ) || (lower.contains("remote branch") + && contains_any(&lower, &["not found", "does not exist", "missing"])) + { + return (GitErrorCategory::UpstreamMissing, 0.9); + } + + if contains_any( + &lower, + &[ + "could not resolve host", + "failed to connect", + "connection timed out", + "network is unreachable", + "connection reset", + "operation timed out", + "temporary failure in name resolution", + "name or service not known", + "could not resolve hostname", + "unable to access", + "couldn't connect to server", + ], + ) { + return (GitErrorCategory::Network, 0.85); + } + + if contains_any( + &lower, + &[ + "authentication failed", + "could not read from remote repository", + "permission denied (publickey)", + "permission to ", + "repository not found", + "terminal prompts disabled", + "could not read username", + "could not read password", + "invalid username or password", + ], + ) { + return (GitErrorCategory::Auth, 0.9); + } + + if contains_any( + &lower, + &[ + "you have unmerged paths", + "fix conflicts and then commit the result", + "resolve all conflicts manually", + "merge is in progress", + "rebase in progress", + "cherry-pick is already in progress", + "revert is already in progress", + "you need to resolve your current index first", + ], + ) { + return (GitErrorCategory::ConflictInProgress, 0.9); + } + + if contains_any( + &lower, + &[ + "index.lock", + "unable to create", + "could not lock config file", + "cannot lock ref", + "failed to lock", + "lock file already exists", + ], + ) { + return (GitErrorCategory::IndexLock, 0.85); + } + + if contains_any( + &lower, + &[ + "not a git repository", + "bad revision", + "ambiguous argument", + "needed a single revision", + "your local changes would be overwritten", + "please commit your changes or stash them", + ], + ) { + return (GitErrorCategory::RepoState, 0.75); + } + + if contains_any( + &lower, + &[ + "permission denied", + "access is denied", + "read-only file system", + "operation not permitted", + ], + ) { + return (GitErrorCategory::Permission, 0.75); + } + + (GitErrorCategory::Other, 0.2) +} + +pub fn interpret_cli_error( + operation: Option<&str>, + stderr: &str, + exit_code: Option, +) -> InterpretedGitError { + let raw_message = match exit_code { + Some(code) if stderr.trim().is_empty() => format!("Git exited with status {code}."), + Some(code) => format!("{stderr}\n(exit status {code})"), + None => stderr.to_string(), + }; + let (category, confidence) = classify_message(stderr); + build_interpretation( + category, + GitBackendSource::GitCli, + operation, + raw_message.trim(), + confidence, + ) +} + +pub fn interpret_gix_error(operation: Option<&str>, err: &E) -> InterpretedGitError +where + E: Error + 'static + ?Sized, +{ + let error_type = type_name::(); + let message = err.to_string(); + let lower_type = error_type.to_ascii_lowercase(); + + let typed = if contains_any(&lower_type, &["transport", "connect", "protocol"]) { + Some((GitErrorCategory::Network, 0.8)) + } else if contains_any(&lower_type, &["auth", "credential"]) { + Some((GitErrorCategory::Auth, 0.8)) + } else if contains_any(&lower_type, &["lock", "transaction"]) { + Some((GitErrorCategory::IndexLock, 0.8)) + } else if contains_any(&lower_type, &["config"]) { + Some((GitErrorCategory::RepoState, 0.7)) + } else if contains_any(&lower_type, &["reference", "object", "revision", "commit"]) { + Some((GitErrorCategory::RepoState, 0.7)) + } else { + None + }; + + let (category, confidence) = typed.unwrap_or_else(|| { + let (category, confidence) = classify_message(&message); + (category, (confidence - 0.15).max(0.1)) + }); + + build_interpretation( + category, + GitBackendSource::Gix, + operation, + &message, + confidence, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug)] + struct FakeTransportError; + + #[derive(Debug)] + struct FakeAuthError; + + #[derive(Debug)] + struct FakeLockTransactionError; + + #[derive(Debug)] + struct FakeConfigError; + + #[derive(Debug)] + struct FakeReferenceRevisionError; + + #[derive(Debug)] + struct FakeFallbackError(&'static str); + + impl std::fmt::Display for FakeTransportError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "handshake failed") + } + } + + impl Error for FakeTransportError {} + + impl std::fmt::Display for FakeAuthError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "credential lookup failed") + } + } + + impl Error for FakeAuthError {} + + impl std::fmt::Display for FakeLockTransactionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "transaction could not write lock") + } + } + + impl Error for FakeLockTransactionError {} + + impl std::fmt::Display for FakeConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "config value is invalid") + } + } + + impl Error for FakeConfigError {} + + impl std::fmt::Display for FakeReferenceRevisionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "revision could not be resolved") + } + } + + impl Error for FakeReferenceRevisionError {} + + impl std::fmt::Display for FakeFallbackError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } + } + + impl Error for FakeFallbackError {} + + #[test] + fn cli_non_fast_forward_wins_before_broader_rejected_text() { + let interpreted = interpret_cli_error( + Some("push"), + "! [rejected] main -> main (fetch first)\nfatal: authentication failed", + Some(1), + ); + + assert_eq!(interpreted.category, GitErrorCategory::NonFastForward); + assert_eq!( + interpreted.suggested_actions, + vec!["fetch", "review", "integrate"] + ); + assert!(interpreted.confidence >= 0.9); + } + + #[test] + fn cli_no_upstream_maps_to_set_upstream_action() { + let interpreted = interpret_cli_error( + Some("push"), + "fatal: The current branch feature has no upstream branch.", + Some(128), + ); + + assert_eq!(interpreted.category, GitErrorCategory::NoUpstream); + assert_eq!(interpreted.suggested_actions, vec!["set-upstream"]); + } + + #[test] + fn cli_auth_and_network_are_distinct() { + let auth = interpret_cli_error( + Some("fetch"), + "Permission denied (publickey). Could not read from remote repository.", + Some(128), + ); + let network = interpret_cli_error( + Some("fetch"), + "fatal: unable to access 'https://example.test/repo.git/': Could not resolve host: example.test", + Some(128), + ); + + assert_eq!(auth.category, GitErrorCategory::Auth); + assert_eq!(network.category, GitErrorCategory::Network); + } + + #[test] + fn cli_upstream_missing_matches_common_remote_ref_messages() { + let interpreted = interpret_cli_error( + Some("push"), + "fatal: couldn't find remote ref refs/heads/main\nerror: no such ref was fetched", + Some(128), + ); + + assert_eq!(interpreted.category, GitErrorCategory::UpstreamMissing); + assert!(interpreted.confidence >= 0.85); + } + + #[test] + fn cli_dns_style_output_maps_to_network() { + let interpreted = interpret_cli_error( + Some("fetch"), + "ssh: Could not resolve hostname example.invalid: Name or service not known\nfatal: Could not read from remote repository.", + Some(128), + ); + + assert_eq!(interpreted.category, GitErrorCategory::Network); + assert!( + interpreted + .suggested_actions + .contains(&"check-network".to_string()) + ); + } + + #[test] + fn cli_conflict_state_maps_to_sequencer_actions() { + let interpreted = interpret_cli_error( + Some("pull"), + "error: you have unmerged paths\nfix conflicts and then commit the result", + Some(1), + ); + + assert_eq!(interpreted.category, GitErrorCategory::ConflictInProgress); + assert!( + interpreted + .suggested_actions + .contains(&"resolve-conflicts".to_string()) + ); + } + + #[test] + fn cli_index_lock_maps_to_unlock_action() { + let interpreted = interpret_cli_error( + Some("commit"), + "fatal: Unable to create '/repo/.git/index.lock': File exists.", + Some(128), + ); + + assert_eq!(interpreted.category, GitErrorCategory::IndexLock); + assert!( + interpreted + .suggested_actions + .contains(&"unlock-index".to_string()) + ); + } + + #[test] + fn cli_unknown_falls_back_to_other_with_low_confidence() { + let interpreted = + interpret_cli_error(Some("status"), "fatal: unexpected frobnication", Some(2)); + + assert_eq!(interpreted.category, GitErrorCategory::Other); + assert!(interpreted.confidence < 0.5); + } + + #[test] + fn gix_typed_transport_family_maps_to_network() { + let err = FakeTransportError; + let interpreted = interpret_gix_error(Some("fetch"), &err); + + assert_eq!(interpreted.category, GitErrorCategory::Network); + assert_eq!(interpreted.backend, GitBackendSource::Gix); + } + + #[test] + fn gix_typed_auth_family_maps_to_auth() { + let err = FakeAuthError; + let interpreted = interpret_gix_error(Some("fetch"), &err); + + assert_eq!(interpreted.category, GitErrorCategory::Auth); + assert_eq!(interpreted.backend, GitBackendSource::Gix); + } + + #[test] + fn gix_typed_lock_transaction_family_maps_to_index_lock() { + let err = FakeLockTransactionError; + let interpreted = interpret_gix_error(Some("commit"), &err); + + assert_eq!(interpreted.category, GitErrorCategory::IndexLock); + } + + #[test] + fn gix_typed_config_family_maps_to_repo_state() { + let err = FakeConfigError; + let interpreted = interpret_gix_error(Some("status"), &err); + + assert_eq!(interpreted.category, GitErrorCategory::RepoState); + } + + #[test] + fn gix_typed_reference_revision_family_maps_to_repo_state() { + let err = FakeReferenceRevisionError; + let interpreted = interpret_gix_error(Some("log"), &err); + + assert_eq!(interpreted.category, GitErrorCategory::RepoState); + } + + #[test] + fn gix_fallback_message_uses_other_for_unknowns() { + let err = FakeFallbackError("unexpected internal state"); + let interpreted = interpret_gix_error(Some("status"), &err); + + assert_eq!(interpreted.category, GitErrorCategory::Other); + assert!(interpreted.confidence < 0.5); + } + + #[test] + fn gix_opaque_message_uses_lower_confidence_heuristics() { + let err = std::io::Error::other("could not lock config file"); + let interpreted = interpret_gix_error(Some("commit"), &err); + + assert_eq!(interpreted.category, GitErrorCategory::IndexLock); + assert!(interpreted.confidence < 0.85); + } +} diff --git a/src-tauri/src/git/gix_handler.rs b/src-tauri/src/git/gix_handler.rs index 66b6451..c8d775e 100644 --- a/src-tauri/src/git/gix_handler.rs +++ b/src-tauri/src/git/gix_handler.rs @@ -4,6 +4,7 @@ use std::{collections::HashMap, collections::HashSet}; use super::cli::CliGitHandler; use super::error::{GitError, GitResult}; +use super::error_interpretation::interpret_gix_error; use super::handler::GitOperationHandler; use super::types::{ AddRemoteRequest, BranchInfo, BranchRequest, CherryPickRequest, CherryPickResult, CloneRequest, @@ -35,13 +36,13 @@ impl GixGitHandler { fn validate_repo_with_gix(&self, repo_path: &str) -> GitResult<()> { let path = Path::new(repo_path.trim()); - gix::discover(path).map_err(|error| GitError::GixError(error.to_string()))?; + gix::discover(path).map_err(|error| Self::gix_error(None, error))?; Ok(()) } fn discover_repo_root(&self, repo_path: &str) -> GitResult { let path = Path::new(repo_path.trim()); - let repo = gix::discover(path).map_err(|error| GitError::GixError(error.to_string()))?; + let repo = gix::discover(path).map_err(|error| Self::gix_error(None, error))?; let root = repo.workdir().unwrap_or(repo.path()); Ok(root.to_string_lossy().to_string()) } @@ -60,6 +61,17 @@ impl GixGitHandler { String::from_utf8_lossy(value.as_ref()).to_string() } + fn gix_error(operation: Option<&str>, error: E) -> GitError + where + E: std::error::Error + 'static, + { + let interpreted = interpret_gix_error(operation, &error); + GitError::GixError { + message: error.to_string(), + interpreted: Some(interpreted), + } + } + fn status_from_worktree_summary( summary: gix::status::index_worktree::iter::Summary, ) -> &'static str { @@ -111,16 +123,19 @@ impl GixGitHandler { let config = repo.config_snapshot(); let refs = repo .references() - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; let mut branches = Vec::new(); let local_iter = refs .local_branches() - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; for reference in local_iter { - let reference = reference.map_err(|e| GitError::GixError(e.to_string()))?; + let reference = reference.map_err(|e| GitError::GixError { + message: e.to_string(), + interpreted: None, + })?; let short_name = Self::bstr_to_string(reference.name().shorten()); let is_current = current_branch_name.as_deref() == Some(short_name.as_str()); @@ -191,10 +206,13 @@ impl GixGitHandler { let remote_iter = refs .remote_branches() - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; for reference in remote_iter { - let reference = reference.map_err(|e| GitError::GixError(e.to_string()))?; + let reference = reference.map_err(|e| GitError::GixError { + message: e.to_string(), + interpreted: None, + })?; let short_name = Self::bstr_to_string(reference.name().shorten()); // Skip symbolic HEAD pointers like "origin/HEAD" if short_name.ends_with("/HEAD") { @@ -264,12 +282,15 @@ impl GixGitHandler { ) -> GitResult> { let refs = repo .references() - .map_err(|e| GitError::GixError(e.to_string()))?; - let tag_iter = refs.tags().map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; + let tag_iter = refs.tags().map_err(|e| Self::gix_error(None, e))?; let mut matching = Vec::new(); for reference in tag_iter { - let reference = reference.map_err(|e| GitError::GixError(e.to_string()))?; + let reference = reference.map_err(|e| GitError::GixError { + message: e.to_string(), + interpreted: None, + })?; let raw_oid = reference.id().detach(); // Peel through annotated tag objects to reach the commit. @@ -291,14 +312,17 @@ impl GixGitHandler { fn collect_tags_with_gix(repo: &gix::Repository) -> GitResult> { let refs = repo .references() - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; - let tag_iter = refs.tags().map_err(|e| GitError::GixError(e.to_string()))?; + let tag_iter = refs.tags().map_err(|e| Self::gix_error(None, e))?; let mut tags = Vec::new(); for reference in tag_iter { - let mut reference = reference.map_err(|e| GitError::GixError(e.to_string()))?; + let mut reference = reference.map_err(|e| GitError::GixError { + message: e.to_string(), + interpreted: None, + })?; let short_name = Self::bstr_to_string(reference.name().shorten()); // The tag object OID (before peeling) - format as hex then truncate to 7 chars @@ -336,7 +360,7 @@ impl GixGitHandler { for name in names.iter() { let remote = repo .find_remote(name.as_ref()) - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; // Prefer the fetch URL; fall back to push URL let url = remote @@ -449,18 +473,18 @@ impl GixGitHandler { Default::default(), )) .all() - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; let mut commits = Vec::with_capacity(limit.min(256)); for info in walk.skip(offset).take(limit) { - let info = info.map_err(|e| GitError::GixError(e.to_string()))?; + let info = info.map_err(|e| Self::gix_error(None, e))?; let oid = info.id(); let commit = repo .find_object(oid) - .map_err(|e| GitError::GixError(e.to_string()))? + .map_err(|e| Self::gix_error(None, e))? .try_into_commit() - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; // Full hex hash let hash = oid.to_hex().to_string(); @@ -470,7 +494,7 @@ impl GixGitHandler { // Author name and email from the author signature let author_sig = commit .author() - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; let author = Self::bstr_to_string(author_sig.name); let author_email = Self::bstr_to_string(author_sig.email); // Date from author or committer signature depending on the setting @@ -479,7 +503,7 @@ impl GixGitHandler { CommitDateMode::CommitterDate => { commit .committer() - .map_err(|e| GitError::GixError(e.to_string()))? + .map_err(|e| Self::gix_error(None, e))? .time } }; @@ -499,7 +523,7 @@ impl GixGitHandler { // Verification happens lazily via verify_commits. let decoded = commit .decode() - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; let sig_value = decoded .extra_headers .iter() @@ -653,12 +677,12 @@ impl GixGitHandler { let mut status_iter = repo .status(gix::progress::Discard) - .map_err(|error| GitError::GixError(error.to_string()))? + .map_err(|error| Self::gix_error(None, error))? .into_iter(Vec::::new()) - .map_err(|error| GitError::GixError(error.to_string()))?; + .map_err(|error| Self::gix_error(None, error))?; while let Some(next_item) = status_iter.next() { - let item = next_item.map_err(|error| GitError::GixError(error.to_string()))?; + let item = next_item.map_err(|error| Self::gix_error(None, error))?; match item { gix::status::Item::IndexWorktree(worktree_item) => { @@ -808,6 +832,7 @@ impl GitOperationHandler for GixGitHandler { output: None, repo_path: Some(resolved_repo_path), backend_used: "gix".to_string(), + interpreted_error: None, }) } @@ -823,19 +848,20 @@ impl GitOperationHandler for GixGitHandler { let should_interrupt = AtomicBool::new(false); let mut prepare = gix::prepare_clone(repo_url, final_destination_str.as_str()) - .map_err(|error| GitError::GixError(error.to_string()))?; + .map_err(|error| Self::gix_error(None, error))?; let (mut checkout, _) = prepare .fetch_then_checkout(gix::progress::Discard, &should_interrupt) - .map_err(|error| GitError::GixError(error.to_string()))?; + .map_err(|error| Self::gix_error(None, error))?; checkout .main_worktree(gix::progress::Discard, &should_interrupt) - .map_err(|error| GitError::GixError(error.to_string()))?; + .map_err(|error| Self::gix_error(None, error))?; Ok(OperationResult { message: format!("Cloned repository to {}", final_destination.display()), output: Some("Clone completed using gix".to_string()), repo_path: Some(final_destination_str), backend_used: "gix".to_string(), + interpreted_error: None, }) } @@ -913,7 +939,7 @@ impl GitOperationHandler for GixGitHandler { fn get_repo_status(&self, request: &RepoRequest) -> GitResult { let repo_path = Path::new(request.repo_path.trim()); - let repo = gix::discover(repo_path).map_err(|error| GitError::GixError(error.to_string())); + let repo = gix::discover(repo_path).map_err(|error| Self::gix_error(None, error)); match repo.and_then(|repository| Self::collect_repo_status_with_gix(&repository)) { Ok(status) => Ok(status), @@ -932,7 +958,7 @@ impl GitOperationHandler for GixGitHandler { let repo_path = Path::new(request.repo_path.trim()); let limit = request.limit.unwrap_or(100).clamp(1, 5000); let offset = request.offset.unwrap_or(0); - let repo = gix::discover(repo_path).map_err(|e| GitError::GixError(e.to_string())); + let repo = gix::discover(repo_path).map_err(|e| Self::gix_error(None, e)); match repo.and_then(|r| { Self::collect_commit_history_with_gix(&r, limit, offset, &request.commit_date_mode) }) { @@ -943,7 +969,7 @@ impl GitOperationHandler for GixGitHandler { fn get_commit_markers(&self, request: &RepoRequest) -> GitResult { let repo_path = Path::new(request.repo_path.trim()); - let repo = gix::discover(repo_path).map_err(|e| GitError::GixError(e.to_string())); + let repo = gix::discover(repo_path).map_err(|e| Self::gix_error(None, e)); match repo.and_then(|r| Self::collect_commit_markers_with_gix(&r)) { Ok(markers) => Ok(markers), Err(_) => self.cli_fallback.get_commit_markers(request), @@ -957,20 +983,20 @@ impl GitOperationHandler for GixGitHandler { fn get_commit_details(&self, request: &CommitDetailsRequest) -> GitResult { let repo_path = Path::new(request.repo_path.trim()); - let repo = gix::discover(repo_path).map_err(|e| GitError::GixError(e.to_string()))?; + let repo = gix::discover(repo_path).map_err(|e| Self::gix_error(None, e))?; let oid = gix::ObjectId::from_hex(request.commit_hash.trim().as_bytes()) - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; let commit = repo .find_object(oid) - .map_err(|e| GitError::GixError(e.to_string()))? + .map_err(|e| Self::gix_error(None, e))? .try_into_commit() - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; let author_sig = commit .author() - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; let author = Self::bstr_to_string(author_sig.name); let author_email = Self::bstr_to_string(author_sig.email); let author_date = gix::date::parse_header(author_sig.time) @@ -979,7 +1005,7 @@ impl GitOperationHandler for GixGitHandler { let committer_sig = commit .committer() - .map_err(|e| GitError::GixError(e.to_string()))?; + .map_err(|e| Self::gix_error(None, e))?; let committer = Self::bstr_to_string(committer_sig.name); let committer_email = Self::bstr_to_string(committer_sig.email); let committer_date = gix::date::parse_header(committer_sig.time) @@ -1020,7 +1046,7 @@ impl GitOperationHandler for GixGitHandler { fn get_branches(&self, request: &RepoRequest) -> GitResult> { let repo_path = Path::new(request.repo_path.trim()); - let repo = gix::discover(repo_path).map_err(|e| GitError::GixError(e.to_string())); + let repo = gix::discover(repo_path).map_err(|e| Self::gix_error(None, e)); match repo.and_then(|repository| Self::collect_branches_with_gix(&repository)) { Ok(branches) => Ok(branches), Err(_) => self.cli_fallback.get_branches(request), @@ -1146,7 +1172,7 @@ impl GitOperationHandler for GixGitHandler { fn get_identity(&self, request: &IdentityRequest) -> GitResult { let repo_path = Path::new(request.repo_path.trim()); - let repo = gix::discover(repo_path).map_err(|e| GitError::GixError(e.to_string())); + let repo = gix::discover(repo_path).map_err(|e| Self::gix_error(None, e)); match repo.and_then(|r| Self::collect_identity_with_gix(&r, &request.scope)) { Ok(identity) => Ok(identity), Err(_) => self.cli_fallback.get_identity(request), @@ -1162,7 +1188,7 @@ impl GitOperationHandler for GixGitHandler { fn get_tags(&self, request: &RepoRequest) -> GitResult> { let repo_path = Path::new(request.repo_path.trim()); - let repo = gix::discover(repo_path).map_err(|e| GitError::GixError(e.to_string())); + let repo = gix::discover(repo_path).map_err(|e| Self::gix_error(None, e)); match repo.and_then(|r| Self::collect_tags_with_gix(&r)) { Ok(tags) => Ok(tags), Err(_) => self.cli_fallback.get_tags(request), @@ -1171,7 +1197,7 @@ impl GitOperationHandler for GixGitHandler { fn get_remotes(&self, request: &RepoRequest) -> GitResult> { let repo_path = Path::new(request.repo_path.trim()); - let repo = gix::discover(repo_path).map_err(|e| GitError::GixError(e.to_string())); + let repo = gix::discover(repo_path).map_err(|e| Self::gix_error(None, e)); match repo.and_then(|r| Self::collect_remotes_with_gix(&r)) { Ok(remotes) => Ok(remotes), Err(_) => self.cli_fallback.get_remotes(request), diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs index e2b3e3b..37d7cf7 100644 --- a/src-tauri/src/git/mod.rs +++ b/src-tauri/src/git/mod.rs @@ -1,6 +1,6 @@ pub mod cli; pub mod error; +pub mod error_interpretation; pub mod gix_handler; pub mod handler; pub mod types; - diff --git a/src-tauri/src/git/types.rs b/src-tauri/src/git/types.rs index 87e9157..289cb73 100644 --- a/src-tauri/src/git/types.rs +++ b/src-tauri/src/git/types.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Deserializer, Serialize}; +use super::error_interpretation::InterpretedGitError; + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum CommitDateMode { AuthorDate, @@ -390,6 +392,8 @@ pub struct OperationResult { pub output: Option, pub repo_path: Option, pub backend_used: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub interpreted_error: Option, } #[derive(Debug, Clone, Serialize)] @@ -501,6 +505,8 @@ pub struct MergeResult { pub output: Option, pub repo_path: Option, pub backend_used: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub interpreted_error: Option, /// True if the merge completed cleanly (fast-forward or auto-merge). pub success: bool, /// True if there are conflicts that require manual resolution. @@ -523,9 +529,12 @@ pub struct RebaseResult { pub output: Option, pub repo_path: Option, pub backend_used: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub interpreted_error: Option, pub success: bool, pub has_conflicts: bool, pub conflicted_files: Vec, + pub rebase_in_progress: bool, } #[derive(Debug, Clone, Deserialize)] @@ -542,6 +551,8 @@ pub struct CherryPickResult { pub output: Option, pub repo_path: Option, pub backend_used: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub interpreted_error: Option, pub success: bool, pub has_conflicts: bool, pub conflicted_files: Vec, @@ -870,6 +881,8 @@ pub struct PushResult { pub backend_used: String, pub success: bool, pub rejection: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub interpreted_error: Option, } #[derive(Debug, Clone, Deserialize)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 61267cf..4417a73 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -93,40 +93,63 @@ fn detect_git_backend() -> GitBackend { } pub(crate) fn git_command() -> std::process::Command { - if let Some(git_exe) = configured_git_executable_path() { + let mut command = if let Some(git_exe) = configured_git_executable_path() { #[cfg(windows)] { let mut command = std::process::Command::new(&git_exe); configure_bundled_git_environment(&mut command, &git_exe); - return command; + command } #[cfg(not(windows))] { - return std::process::Command::new(git_exe); + std::process::Command::new(git_exe) } - } - - match git_backend() { - GitBackend::FlatpakHost => { - let mut cmd = std::process::Command::new("flatpak-spawn"); - cmd.args(["--host", "git"]); - cmd - } - GitBackend::FlatpakBundled => std::process::Command::new("/app/bin/git"), - GitBackend::System => { - #[cfg(windows)] - { - let git_exe = resolve_git_exe(); - let mut command = std::process::Command::new(&git_exe); - configure_bundled_git_environment(&mut command, Path::new(&git_exe)); - command + } else { + match git_backend() { + GitBackend::FlatpakHost => { + let mut cmd = std::process::Command::new("flatpak-spawn"); + cmd.args(["--host", "--env=LC_ALL=C", "--env=LANG=C", "git"]); + cmd } - #[cfg(not(windows))] - { - std::process::Command::new(resolve_git_exe()) + GitBackend::FlatpakBundled => std::process::Command::new("/app/bin/git"), + GitBackend::System => { + #[cfg(windows)] + { + let git_exe = resolve_git_exe(); + let mut command = std::process::Command::new(&git_exe); + configure_bundled_git_environment(&mut command, Path::new(&git_exe)); + command + } + #[cfg(not(windows))] + { + std::process::Command::new(resolve_git_exe()) + } } } + }; + + command.env("LC_ALL", "C"); + command.env("LANG", "C"); + command +} + +#[cfg(test)] +mod git_command_tests { + fn command_env_is(command: &std::process::Command, key: &str, value: &str) -> bool { + command + .get_envs() + .any(|(env_key, env_value)| { + env_key == key && env_value == Some(std::ffi::OsStr::new(value)) + }) + } + + #[test] + fn git_command_uses_stable_machine_locale() { + let command = crate::git_command(); + + assert!(command_env_is(&command, "LC_ALL", "C")); + assert!(command_env_is(&command, "LANG", "C")); } } @@ -305,6 +328,76 @@ fn resolve_git_bash_exe() -> Option { .find(|candidate| candidate.exists()) } +#[cfg(windows)] +pub(crate) fn resolve_system_git_bash_exe() -> Option { + fn git_bash_exe_from_git_exe(git_exe: &Path) -> Option { + let parent = git_exe.parent()?; + let root = if parent + .file_name() + .is_some_and(|name| name == "cmd" || name == "bin") + { + parent.parent()? + } else { + parent + }; + let candidate = root.join("git-bash.exe"); + if candidate.exists() { + Some(candidate) + } else { + None + } + } + + fn is_bundled_path(candidate: &Path) -> bool { + let Some(bundled) = bundled_git_exe() else { + return false; + }; + let Some(root) = bundled.parent().and_then(|path| path.parent()) else { + return false; + }; + candidate.starts_with(root) + } + + fn is_git_for_windows_bash(candidate: &Path) -> bool { + candidate.exists() + && !is_bundled_path(candidate) + && candidate + .parent() + .is_some_and(|root| root.join("cmd").join("git.exe").exists()) + } + + let active_git = active_windows_git_exe_path(); + if active_git.is_absolute() && !is_bundled_path(&active_git) { + if let Some(candidate) = git_bash_exe_from_git_exe(&active_git) { + if is_git_for_windows_bash(&candidate) { + return Some(candidate); + } + } + } + + for candidate in [ + r"C:\Program Files\Git\git-bash.exe", + r"C:\Program Files (x86)\Git\git-bash.exe", + ] { + let candidate = PathBuf::from(candidate); + if is_git_for_windows_bash(&candidate) { + return Some(candidate); + } + } + + if let Some(path) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&path) { + if let Some(candidate) = git_bash_exe_from_git_exe(&dir.join("git.exe")) { + if is_git_for_windows_bash(&candidate) { + return Some(candidate); + } + } + } + } + + None +} + #[cfg(windows)] pub(crate) fn git_bash_command() -> Option { resolve_git_bash_exe().map(std::process::Command::new) @@ -1314,6 +1407,8 @@ pub fn run() { commands::repo::get_commit_details, commands::repo::validate_repo_path, commands::repo::init_repo, + commands::repo::get_repo_open_locations, + commands::repo::open_repo_location, commands::repo::clone_repo, commands::repo::cancel_clone, commands::repo::get_default_clone_dir, diff --git a/src-tauri/tests/git.rs b/src-tauri/tests/git.rs index 7b54da4..b0b8bd1 100644 --- a/src-tauri/tests/git.rs +++ b/src-tauri/tests/git.rs @@ -4,12 +4,13 @@ use std::process::Command; use tempfile::TempDir; use gitmun_lib::git::cli::CliGitHandler; +use gitmun_lib::git::error_interpretation::GitErrorCategory; use gitmun_lib::git::gix_handler::GixGitHandler; use gitmun_lib::git::handler::GitOperationHandler; use gitmun_lib::git::types::{ CommitDetailsRequest, CommitHistoryRequest, CommitLogScope, CommitRequest, CreateBranchRequest, - FileRequest, PushRequest, RepoRequest, SetBranchUpstreamRequest, StageFilesRequest, - SubmoduleActionRequest, SubmoduleState, + FileRequest, PushFailureKind, PushRequest, RepoRequest, SetBranchUpstreamRequest, + StageFilesRequest, SubmoduleActionRequest, SubmoduleState, }; fn init_repo() -> TempDir { @@ -947,11 +948,18 @@ fn push_changes_classifies_non_fast_forward() { .expect("push_changes"); assert!(!result.success); + let interpreted = result.interpreted_error.expect("interpreted error"); + assert_eq!(interpreted.category, GitErrorCategory::NonFastForward); + assert!(interpreted.confidence >= 0.9); + assert!( + result + .output + .as_deref() + .is_some_and(|output| output.contains("[rejected]") || output.contains("fetch first")), + "raw Git output is preserved" + ); let rejection = result.rejection.expect("push rejection"); - assert!(matches!( - rejection.kind, - gitmun_lib::git::types::PushFailureKind::NonFastForward - )); + assert!(matches!(rejection.kind, PushFailureKind::NonFastForward)); } #[test] @@ -1092,11 +1100,89 @@ fn push_without_upstream_returns_publish_guidance() { .expect("push_changes"); assert!(!result.success); + let interpreted = result.interpreted_error.expect("interpreted error"); + assert_eq!(interpreted.category, GitErrorCategory::NoUpstream); + assert_eq!(interpreted.suggested_actions, vec!["set-upstream"]); + assert!( + result + .output + .as_deref() + .is_some_and(|output| output.contains("no upstream branch")), + "raw Git output is preserved" + ); let rejection = result.rejection.expect("push rejection"); - assert!(matches!( - rejection.kind, - gitmun_lib::git::types::PushFailureKind::NoUpstream - )); + assert!(matches!(rejection.kind, PushFailureKind::NoUpstream)); +} + +#[test] +fn push_with_mismatched_upstream_returns_repair_guidance() { + let (_remote, local) = init_remote_with_clone(); + git( + local.path(), + &["config", "branch.main.merge", "refs/heads/missing"], + ); + + let result = handler() + .push_changes(&push_request(&local)) + .expect("push_changes"); + + assert!(!result.success); + let interpreted = result.interpreted_error.expect("interpreted error"); + assert_eq!(interpreted.category, GitErrorCategory::UpstreamMissing); + assert!( + interpreted + .suggested_actions + .contains(&"repair-upstream".to_string()) + ); + let rejection = result.rejection.expect("push rejection"); + assert!(matches!(rejection.kind, PushFailureKind::UpstreamMissing)); + assert!( + result + .output + .as_deref() + .is_some_and(|output| output.contains("upstream branch")), + "raw Git output is preserved" + ); +} + +#[test] +fn push_to_unresolvable_remote_returns_network_guidance() { + let (_remote, local) = init_remote_with_clone(); + write_file(local.path(), "network.txt", "network"); + git(local.path(), &["add", "network.txt"]); + git(local.path(), &["commit", "-m", "network"]); + git( + local.path(), + &[ + "config", + "core.sshCommand", + "sh -c 'echo \"ssh: Could not resolve hostname example.invalid: Name or service not known\" >&2; exit 255'", + ], + ); + git( + local.path(), + &[ + "remote", + "set-url", + "origin", + "ssh://git@example.invalid/repo.git", + ], + ); + + let result = handler() + .push_changes(&push_request(&local)) + .expect("push_changes"); + + assert!(!result.success); + let interpreted = result.interpreted_error.expect("interpreted error"); + assert_eq!(interpreted.category, GitErrorCategory::Network); + assert!( + result + .output + .as_deref() + .is_some_and(|output| output.contains("Could not resolve hostname")), + "raw Git output is preserved" + ); } #[test] diff --git a/src/api/commands.ts b/src/api/commands.ts index 3f15edf..524b6ed 100644 --- a/src/api/commands.ts +++ b/src/api/commands.ts @@ -36,6 +36,8 @@ import type { PushRequest, PushResult, RemoteInfo, + RepoOpenLocation, + RepoOpenLocationKind, RepoRequest, RepoStatus, RepoOpenBehaviour, @@ -69,6 +71,14 @@ export function getRepoStatus(repoPath: string): Promise { return invoke("get_repo_status", {request: {repoPath}}); } +export function getRepoOpenLocations(): Promise { + return invoke("get_repo_open_locations"); +} + +export function openRepoLocation(repoPath: string, kind: RepoOpenLocationKind): Promise { + return invoke("open_repo_location", {repoPath, kind}); +} + export function getNumstat(repoPath: string, filePath: string, staged: boolean): Promise { return invoke("get_numstat", {request: {repoPath, filePath, staged}}); } diff --git a/src/components/App.tsx b/src/components/App.tsx index 096fb04..71a2b12 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -10,7 +10,7 @@ import {useToast} from "../hooks/useToast"; import {useUpdateFlow} from "../hooks/useUpdateFlow"; import {usePlatform} from "../hooks/usePlatform"; import * as api from "../api/commands"; -import type {AppAvailableUpdate, RepoOpenBehaviour, Settings, ShellStartupAction} from "../types"; +import type {AppAvailableUpdate, RepoOpenBehaviour, RepoOpenLocationKind, Settings, ShellStartupAction} from "../types"; import {appendResultLog, setResultLogRepoPath} from "../utils/resultLog"; import {applyThemeMode} from "../utils/theme"; import {applyUiTextScale} from "../utils/uiTextScale"; @@ -632,6 +632,18 @@ export function App() { await openRepoPath(path); }, [openRepoPath]); + const handleOpenRepoLocation = useCallback(async (kind: RepoOpenLocationKind) => { + if (!repoPath) return; + try { + const result = await api.openRepoLocation(repoPath, kind); + showToast(result.message, "success"); + appendResultLog("info", result.message, result.backendUsed, repoPath); + } catch (e) { + showToast(String(e), "error"); + appendResultLog("error", t("log.openRepoLocationFailed", {message: String(e)}), "unknown", repoPath); + } + }, [repoPath, showToast, t]); + const isNative = true; const winRadius = 0; @@ -655,6 +667,7 @@ export function App() { identityOpen={identityOpen} onIdentityToggle={() => setIdentityOpen(v => !v)} onRepoSelect={handleRepoSelect} + onOpenRepoLocation={handleOpenRepoLocation} onOpenExistingClick={handleOpenExistingClick} onCloneClick={handleCloneClick} onInitRepoClick={handleInitRepoClick} diff --git a/src/components/ProjectView.test.ts b/src/components/ProjectView.test.ts index 7301507..a94e149 100644 --- a/src/components/ProjectView.test.ts +++ b/src/components/ProjectView.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import i18n from "../i18n"; -import { buildStashDropPrompt, getEffectiveCommitAction } from "./ProjectView"; +import { buildStashDropPrompt, getEffectiveCommitAction, shouldForceWithLeaseAfterRebase } from "./ProjectView"; const t = i18n.getFixedT("en", "projectView"); @@ -25,3 +25,17 @@ describe("getEffectiveCommitAction", () => { expect(getEffectiveCommitAction("commitAndPush", true)).toBe("commitAndPush"); }); }); + +describe("shouldForceWithLeaseAfterRebase", () => { + it("forces the next push on the rebased branch", () => { + expect(shouldForceWithLeaseAfterRebase("main", "main")).toBe(true); + }); + + it("does not force pushes on other branches", () => { + expect(shouldForceWithLeaseAfterRebase("main", "feature")).toBe(false); + }); + + it("does not force pushes without a completed rebase", () => { + expect(shouldForceWithLeaseAfterRebase(null, "main")).toBe(false); + }); +}); diff --git a/src/components/ProjectView.tsx b/src/components/ProjectView.tsx index bd8c03a..10f18a8 100644 --- a/src/components/ProjectView.tsx +++ b/src/components/ProjectView.tsx @@ -51,6 +51,7 @@ import type { PushRequest, PushRejectionAnalysis, RemoteInfo, + RepoOpenLocationKind, RowStriping, StashEntry, } from "../types"; @@ -58,6 +59,7 @@ import type { ResultLogEntry } from "../utils/resultLog"; import { appendResultLog } from "../utils/resultLog"; import type { PlatformType } from "../hooks/usePlatform"; import type { ToastType } from "../hooks/useToast"; +import { buildPushFailureDisplay } from "../utils/gitErrorDisplay"; import { getRemoteActionState } from "../utils/remoteActionState"; // Tracks whether the no-diff-tool warning has already been shown this session @@ -128,6 +130,13 @@ export function getEffectiveCommitAction( return canCommitAndPush ? selectedAction : "commit"; } +export function shouldForceWithLeaseAfterRebase( + rebasedBranchAwaitingPush: string | null, + currentBranch: string | null, +): boolean { + return rebasedBranchAwaitingPush !== null && rebasedBranchAwaitingPush === currentBranch; +} + export type ProjectViewProps = { /** The active repository path. Changing this key causes a full remount. */ repoPath: string | null; @@ -139,6 +148,7 @@ export type ProjectViewProps = { identityOpen: boolean; onIdentityToggle: () => void; onRepoSelect: (path: string) => void; + onOpenRepoLocation: (kind: RepoOpenLocationKind) => void; onOpenExistingClick: () => void; onCloneClick: () => void; onInitRepoClick: () => void; @@ -167,6 +177,7 @@ export function ProjectView({ identityOpen, onIdentityToggle, onRepoSelect, + onOpenRepoLocation, onOpenExistingClick, onCloneClick, onInitRepoClick, @@ -185,6 +196,7 @@ export function ProjectView({ winRadius, }: ProjectViewProps) { const { t } = useTranslation("projectView"); + const { t: tGitAdvice } = useTranslation("gitAdvice"); const collapsedRightPaneBonus = leftPaneCollapsed ? Math.max(0, leftPaneWidth + 6 - 22) : 0; @@ -239,6 +251,7 @@ export function ProjectView({ const [commitPrimaryAction, setCommitPrimaryActionState] = useState("commit"); const [commitMessageRecommendedLength, setCommitMessageRecommendedLength] = useState(72); const [pushFollowTags, setPushFollowTags] = useState(false); + const [rebasedBranchAwaitingPush, setRebasedBranchAwaitingPush] = useState(null); const [wrapDiffLines, setWrapDiffLines] = useState(false); const [rowStriping, setRowStriping] = useState("Off"); const [searchQuery, setSearchQuery] = useState(""); @@ -292,6 +305,7 @@ export function ProjectView({ const currentBranchInfo = branches.find(b => b.isCurrent && !b.isRemote); const remoteBranches = branches.filter(b => b.isRemote); const remoteActionState = getRemoteActionState(currentBranch, currentBranchInfo); + const forceWithLeaseAfterRebase = shouldForceWithLeaseAfterRebase(rebasedBranchAwaitingPush, currentBranch); const canCommitAndPush = currentBranchInfo?.upstreamStatus === "tracked"; const effectiveCommitAction = getEffectiveCommitAction(commitPrimaryAction, canCommitAndPush); const remoteActionLabel = remoteActionState.kind === "publish" @@ -303,7 +317,9 @@ export function ProjectView({ ? t("remoteAction.detachedTitle", { ns: "git" }) : remoteActionState.kind === "repair-upstream" ? t("remoteAction.repairUpstreamTitle", { ns: "git" }) - : undefined; + : forceWithLeaseAfterRebase + ? t("remoteAction.forceWithLeaseTitle", { ns: "git" }) + : undefined; useEffect(() => { setLogScope("currentCheckout"); @@ -722,16 +738,16 @@ export function ProjectView({ }, [runPullWithStrategy]); const handlePushFailure = useCallback((result: Awaited>) => { - if (result.rejection && ["non-fast-forward", "no-upstream", "upstream-missing"].includes(result.rejection.kind)) { - setPushRejectionAnalysis(result.rejection); - showToast(result.rejection.message, "error"); - appendResultLog("error", result.rejection.message, result.backendUsed); + const display = buildPushFailureDisplay(result, tGitAdvice); + if (display.dialogRejection) { + setPushRejectionAnalysis(display.dialogRejection); + appendResultLog("error", display.logMessage, result.backendUsed, undefined, display.logDetails); return; } - showToast(result.message, "error"); - appendResultLog("error", result.output?.trim() || result.message, result.backendUsed); - }, [showToast]); + showToast(display.toastMessage ?? result.message, "error"); + appendResultLog("error", display.logMessage, result.backendUsed, undefined, display.logDetails); + }, [showToast, tGitAdvice]); const runPushRequest = useCallback(async ( request: PushRequest, @@ -751,6 +767,9 @@ export function ProjectView({ } showToast(successToast); appendResultLog("success", result.message, result.backendUsed); + if (request.forceWithLease) { + setRebasedBranchAwaitingPush(null); + } await refreshAll(); } catch (e) { showToast(String(e), "error"); @@ -782,9 +801,10 @@ export function ProjectView({ await runPushRequest({ repoPath, + forceWithLease: forceWithLeaseAfterRebase, pushFollowTags, }, t("toast.pushComplete"), t("toast.pushFailed")); - }, [remoteActionState, remoteActionTitle, repoPath, remoteOp, runPushRequest, showToast, pushFollowTags, t]); + }, [forceWithLeaseAfterRebase, remoteActionState, remoteActionTitle, repoPath, remoteOp, runPushRequest, showToast, pushFollowTags, t]); const handleCommitAndPush = useCallback(async (message: string, amend: boolean) => { setIsCommitting(true); @@ -1528,6 +1548,7 @@ export function ProjectView({ } else { showToast(result.message, "success"); appendResultLog("success", result.message, result.backendUsed); + setRebasedBranchAwaitingPush(currentBranch); } await refreshAll(); } catch (e) { @@ -1550,6 +1571,9 @@ export function ProjectView({ } else { showToast(result.message, "success"); appendResultLog("success", result.message, result.backendUsed); + if (!result.rebaseInProgress) { + setRebasedBranchAwaitingPush(currentBranch); + } } await refreshAll(); } catch (e) { @@ -1558,7 +1582,7 @@ export function ProjectView({ } finally { setIsRebaseActionRunning(false); } - }, [repoPath, rebaseInProgress, refreshAll, showToast, t]); + }, [repoPath, rebaseInProgress, currentBranch, refreshAll, showToast, t]); const handleRebaseAbort = useCallback(async () => { if (!repoPath || !rebaseInProgress) return; @@ -2011,6 +2035,7 @@ export function ProjectView({ onInitRepoClick={onInitRepoClick} onOpenExistingClick={onOpenExistingClick} onRepoSelect={onRepoSelect} + onOpenRepoLocation={onOpenRepoLocation} onFetch={handleFetch} onPull={handlePull} onPush={handlePush} diff --git a/src/components/Titlebar.css b/src/components/Titlebar.css index 9f66d06..53b3c37 100644 --- a/src/components/Titlebar.css +++ b/src/components/Titlebar.css @@ -24,7 +24,6 @@ .titlebar__name { font-weight: var(--font-weight-semibold); font-size: var(--font-size-lg); - letter-spacing: -0.01em; } .titlebar__sep { @@ -77,6 +76,13 @@ min-width: 0; } +.titlebar__repo-actions { + display: flex; + align-items: center; + gap: 2px; + min-width: 0; +} + .titlebar__action-btn { display: flex; align-items: center; @@ -202,6 +208,14 @@ color: var(--text-primary); } +.titlebar__icon-btn--disabled, +.titlebar__icon-btn--disabled:hover { + opacity: 0.35; + cursor: default; + background: transparent; + color: var(--text-secondary); +} + .titlebar__icon-btn--labeled { display: flex; align-items: center; @@ -248,6 +262,12 @@ color: var(--text-primary); } +.titlebar__open-menu-icon { + width: 14px; + height: 14px; + flex: 0 0 14px; +} + .titlebar__open-menu-item--recent { font-family: var(--font-mono); overflow: hidden; @@ -358,3 +378,82 @@ width: 200px; } } + +@media (max-width: 1260px) { + .titlebar__repo-actions .titlebar__btn-label { + display: none; + } + + .titlebar__repo-actions .titlebar__icon-btn--labeled { + padding: 5px; + } + + .titlebar__search { + min-width: 150px; + width: 150px; + } + + .titlebar__search--active { + width: 176px; + } +} + +@media (max-width: 1160px) { + .titlebar__actions .titlebar__btn-label { + display: none; + } + + .titlebar__action-btn { + position: relative; + padding: 5px; + gap: 0; + } + + .titlebar__badge { + position: absolute; + top: -4px; + right: -4px; + line-height: 14px; + min-width: 14px; + box-sizing: border-box; + text-align: center; + } + + .titlebar__search-hint { + display: none; + } +} + +@media (max-width: 1040px) { + .titlebar { + gap: 6px; + } + + .titlebar__sep { + margin: 0 2px; + } + + .titlebar__repo-dir { + display: none; + } + + .titlebar__branch-pill { + max-width: 150px; + min-width: 0; + } + + .titlebar__branch-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .titlebar__search { + min-width: 118px; + width: 118px; + } + + .titlebar__search--active { + width: 150px; + } +} diff --git a/src/components/Titlebar.test.tsx b/src/components/Titlebar.test.tsx index 8651073..b2b3fac 100644 --- a/src/components/Titlebar.test.tsx +++ b/src/components/Titlebar.test.tsx @@ -1,11 +1,21 @@ // @vitest-environment jsdom import React from "react"; -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { Titlebar } from "./Titlebar"; import type { BranchInfo } from "../types"; import "../i18n"; +vi.mock("../api/commands", () => ({ + getRepoOpenLocations: vi.fn(async () => [ + { kind: "fileExplorer", label: "Explorer App", fallbackLabel: "File Manager", iconDataUrl: null }, + { kind: "terminal", label: "Terminal App", fallbackLabel: "Terminal", iconDataUrl: null }, + { kind: "gitBash", label: "Git Bash", fallbackLabel: "Git Bash", iconDataUrl: null }, + ]), +})); + +import * as api from "../api/commands"; + function makeBranch(overrides: Partial = {}): BranchInfo { return { name: "feature/demo", @@ -19,13 +29,18 @@ function makeBranch(overrides: Partial = {}): BranchInfo { }; } -function renderTitlebar(branches: BranchInfo[], pushLabel = "Push") { +function renderTitlebar( + branches: BranchInfo[], + pushLabel = "Push", + repoPath: string | null = "/repo", + onOpenRepoLocation = vi.fn(), +) { render( { + beforeEach(() => { + vi.mocked(api.getRepoOpenLocations).mockResolvedValue([ + { kind: "fileExplorer", label: "Explorer App", fallbackLabel: "File Manager", iconDataUrl: null }, + { kind: "terminal", label: "Terminal App", fallbackLabel: "Terminal", iconDataUrl: null }, + { kind: "gitBash", label: "Git Bash", fallbackLabel: "Git Bash", iconDataUrl: null }, + ]); + }); + it("shows Publish when the current branch has no upstream", () => { renderTitlebar([makeBranch()], "Publish"); expect(screen.getByText("Publish")).toBeInTheDocument(); @@ -61,4 +85,59 @@ describe("Titlebar", () => { renderTitlebar([makeBranch({ upstream: "origin/feature/demo", upstreamStatus: "tracked" })], "Push"); expect(screen.getByText("Push")).toBeInTheDocument(); }); + + it("disables Open in when no repository is open", () => { + renderTitlebar([], "Push", null); + + const button = screen.getByText("Open in...").closest(".titlebar__icon-btn"); + expect(button).toHaveAttribute("aria-disabled", "true"); + + fireEvent.click(screen.getByText("Open in...")); + expect(screen.queryByText("Explorer App")).not.toBeInTheDocument(); + expect(screen.queryByText("Terminal App")).not.toBeInTheDocument(); + }); + + it("shows file manager and terminal entries when a repository is open", async () => { + renderTitlebar([makeBranch()]); + + fireEvent.click(screen.getByText("Open in...")); + + expect(await screen.findByText("Explorer App")).toBeInTheDocument(); + expect(screen.getByText("Terminal App")).toBeInTheDocument(); + expect(screen.getByText("Git Bash")).toBeInTheDocument(); + }); + + it("calls the open handler with the selected location", async () => { + const onOpenRepoLocation = vi.fn(); + renderTitlebar([makeBranch()], "Push", "/repo", onOpenRepoLocation); + + fireEvent.click(screen.getByText("Open in...")); + fireEvent.click(await screen.findByText("Explorer App")); + expect(onOpenRepoLocation).toHaveBeenCalledWith("fileExplorer"); + + fireEvent.click(screen.getByText("Open in...")); + fireEvent.click(await screen.findByText("Terminal App")); + expect(onOpenRepoLocation).toHaveBeenCalledWith("terminal"); + + fireEvent.click(screen.getByText("Open in...")); + fireEvent.click(await screen.findByText("Git Bash")); + expect(onOpenRepoLocation).toHaveBeenCalledWith("gitBash"); + }); + + it("renders fallback labels when native labels are empty", async () => { + vi.mocked(api.getRepoOpenLocations).mockResolvedValue([ + { kind: "fileExplorer", label: "", fallbackLabel: "File Manager", iconDataUrl: null }, + { kind: "terminal", label: "", fallbackLabel: "Terminal", iconDataUrl: null }, + { kind: "gitBash", label: "", fallbackLabel: "Git Bash", iconDataUrl: null }, + ]); + + renderTitlebar([makeBranch()]); + fireEvent.click(screen.getByText("Open in...")); + + await waitFor(() => { + expect(screen.getByText("File Manager")).toBeInTheDocument(); + expect(screen.getByText("Terminal")).toBeInTheDocument(); + expect(screen.getByText("Git Bash")).toBeInTheDocument(); + }); + }); }); diff --git a/src/components/Titlebar.tsx b/src/components/Titlebar.tsx index 6f0e797..ed98f51 100644 --- a/src/components/Titlebar.tsx +++ b/src/components/Titlebar.tsx @@ -2,10 +2,11 @@ import React, { useState, useEffect, useRef, RefObject } from "react"; import { useTranslation } from "react-i18next"; import { GitIcon, BranchIcon, FetchIcon, PullIcon, PushIcon, - StashIcon, SearchIcon, SettingsIcon, FolderIcon, CopyIcon, ChevDownIcon, InfoIcon, + StashIcon, SearchIcon, SettingsIcon, FolderIcon, CopyIcon, ChevDownIcon, InfoIcon, TerminalIcon, OpenExternalIcon, } from "./icons"; +import * as api from "../api/commands"; import type { PlatformType } from "../hooks/usePlatform"; -import type { BranchInfo } from "../types"; +import type { BranchInfo, RepoOpenLocation, RepoOpenLocationKind } from "../types"; import "./Titlebar.css"; type TitlebarProps = { @@ -28,6 +29,7 @@ type TitlebarProps = { onInitRepoClick: () => void; onOpenExistingClick: () => void; onRepoSelect: (path: string) => void; + onOpenRepoLocation: (kind: RepoOpenLocationKind) => void; onFetch: () => void; onPull: () => void; onPush: () => void; @@ -56,7 +58,7 @@ export function Titlebar({ platform, native, repoPath, currentBranch, branches, identityInitials, identityAvatarUrl, recentRepos, searchQuery, searchInputRef, onSearchChange, onAboutClick, onSettingsClick, onIdentityClick, onCloneClick, onInitRepoClick, onOpenExistingClick, - onRepoSelect, onFetch, onPull, onPush, pushLabel, pushDisabled = false, pushTitle, onStash, + onRepoSelect, onOpenRepoLocation, onFetch, onPull, onPush, pushLabel, pushDisabled = false, pushTitle, onStash, identityOpen, remoteOp, }: TitlebarProps) { const { t } = useTranslation("titlebar"); @@ -121,19 +123,24 @@ export function Titlebar({
- {/* New / clone / open repo */} -
- {t("actions.new")} -
-
- {t("actions.clone")} +
+
+ {t("actions.new")} +
+
+ {t("actions.clone")} +
+ +
-
{/* Search */} @@ -182,6 +189,103 @@ export function Titlebar({ ); } +function fallbackOpenLocations(t: ReturnType>["t"]): RepoOpenLocation[] { + return [ + { + kind: "fileExplorer", + label: t("actions.fileManager"), + fallbackLabel: t("actions.fileManager"), + iconDataUrl: null, + }, + { + kind: "terminal", + label: t("actions.terminal"), + fallbackLabel: t("actions.terminal"), + iconDataUrl: null, + }, + ]; +} + +function OpenInDropdown({ repoPath, onOpenRepoLocation }: { + repoPath: string | null; + onOpenRepoLocation: (kind: RepoOpenLocationKind) => void; +}) { + const { t } = useTranslation("titlebar"); + const [open, setOpen] = useState(false); + const [locations, setLocations] = useState(() => fallbackOpenLocations(t)); + const ref = useRef(null); + const disabled = !repoPath; + + useEffect(() => { + if (!open) return; + let cancelled = false; + api.getRepoOpenLocations() + .then(result => { + if (!cancelled && result.length > 0) { + setLocations(result); + } + }) + .catch(() => { + if (!cancelled) { + setLocations(fallbackOpenLocations(t)); + } + }); + return () => { cancelled = true; }; + }, [open, t]); + + useEffect(() => { + if (disabled) { + setOpen(false); + } + }, [disabled]); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + return ( +
+
setOpen(v => !v)} + title={t("actions.openIn")} + aria-disabled={disabled} + > + + {t("actions.openIn")} + +
+ {open && !disabled && ( +
+ {locations.map(location => ( +
{ setOpen(false); onOpenRepoLocation(location.kind); }} + > + {location.iconDataUrl ? ( + + ) : location.kind === "terminal" || location.kind === "gitBash" ? ( + + ) : ( + + )} + {location.label || location.fallbackLabel} +
+ ))} +
+ )} +
+ ); +} + function OpenDropdown({ repoPath, recentRepos, onOpenExistingClick, onRepoSelect }: { repoPath: string | null; recentRepos: string[]; @@ -254,7 +358,7 @@ function ActionBtn({ icon, label, badge, onClick, disabled, loading, title }: {
{loading ? : icon} {label} diff --git a/src/components/about/AboutWindow.tsx b/src/components/about/AboutWindow.tsx index c02e54c..d0a0448 100644 --- a/src/components/about/AboutWindow.tsx +++ b/src/components/about/AboutWindow.tsx @@ -88,7 +88,7 @@ export function AboutWindow() {