From bfbc9a221bfb1575bc4a2608eb8f133bbf72818f Mon Sep 17 00:00:00 2001 From: NyanHeart Date: Wed, 14 Jan 2026 21:55:22 +0800 Subject: [PATCH 1/2] Feature: Enhanced Git Support (SSH Agent & Status Center) (#17692) - Implemented SSH key authentication by integrating with the system OpenSSH Agent, resolving compatibility issues with LibGit2Sharp 0.31.0 and enabling support for passphrase-protected keys. - Integrated Git Push, Fetch, and Pull operations with the Status Center to provide real-time progress feedback (items processed, transfer bytes). - Fixed the issue where the Git Status Bar (commits ahead/behind) would not refresh automatically after remote operations. - Refactored FetchOrigin and PullOriginAsync to use async/await and perform operations on background threads, preventing UI freezes. - Removed the obsolete GitPassphraseDialog and custom SSH credential handling logic in favor of the native agent. - Added GitFetch and GitPull to FileOperationType and updated Resources.resw with localized strings for these operations. Closes #17692 --- Directory.Packages.props | 3 +- src/Files.App/Actions/Git/GitFetchAction.cs | 6 +- src/Files.App/Data/Enums/FileOperationType.cs | 15 ++ src/Files.App/Strings/en-US/Resources.resw | 96 +++++++ src/Files.App/Utils/Git/GitHelpers.cs | 239 +++++++++++++----- .../Utils/StatusCenter/StatusCenterHelper.cs | 219 ++++++++++++++++ 6 files changed, 508 insertions(+), 70 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 4f0b23586d00..495d82dff260 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -21,8 +21,7 @@ - - + diff --git a/src/Files.App/Actions/Git/GitFetchAction.cs b/src/Files.App/Actions/Git/GitFetchAction.cs index 26e84936f9d8..bd5260948c94 100644 --- a/src/Files.App/Actions/Git/GitFetchAction.cs +++ b/src/Files.App/Actions/Git/GitFetchAction.cs @@ -24,11 +24,9 @@ public GitFetchAction() _context.PropertyChanged += Context_PropertyChanged; } - public Task ExecuteAsync(object? parameter = null) + public async Task ExecuteAsync(object? parameter = null) { - GitHelpers.FetchOrigin(_context.ShellPage!.InstanceViewModel.GitRepositoryPath); - - return Task.CompletedTask; + await GitHelpers.FetchOrigin(_context.ShellPage!.InstanceViewModel.GitRepositoryPath); } private void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e) diff --git a/src/Files.App/Data/Enums/FileOperationType.cs b/src/Files.App/Data/Enums/FileOperationType.cs index dfa51b51c514..21d632f383ed 100644 --- a/src/Files.App/Data/Enums/FileOperationType.cs +++ b/src/Files.App/Data/Enums/FileOperationType.cs @@ -72,5 +72,20 @@ public enum FileOperationType : byte /// A font has been installed /// InstallFont = 13, + + /// + /// A git repo has been pushed + /// + GitPush = 14, + + /// + /// A git repo has been fetched + /// + GitFetch = 15, + + /// + /// A git repo has been pulled + /// + GitPull = 16, } } diff --git a/src/Files.App/Strings/en-US/Resources.resw b/src/Files.App/Strings/en-US/Resources.resw index 1de736ba80bb..74309405b274 100644 --- a/src/Files.App/Strings/en-US/Resources.resw +++ b/src/Files.App/Strings/en-US/Resources.resw @@ -3738,6 +3738,102 @@ Failed to empty Recycle Bin Shown in a StatusCenter card. + + Canceled pushing to "{0}" + Shown in a StatusCenter card. + + + Canceled pushing branch "{0}" to "{1}" + Shown in a StatusCenter card. + + + Pushed to "{0}" + Shown in a StatusCenter card. + + + Pushed branch "{0}" to "{1}" + Shown in a StatusCenter card. + + + Error pushing to "{0}" + Shown in a StatusCenter card. + + + Failed to push branch "{0}" to "{1}" + Shown in a StatusCenter card. + + + Pushing to "{0}" + Shown in a StatusCenter card. + + + Pushing branch "{0}" to "{1}" + Shown in a StatusCenter card. + + + Canceled fetching from "{0}" + Shown in a StatusCenter card. + + + Canceled fetching from "{0}" + Shown in a StatusCenter card. + + + Fetched from "{0}" + Shown in a StatusCenter card. + + + Fetched from "{0}" + Shown in a StatusCenter card. + + + Error fetching from "{0}" + Shown in a StatusCenter card. + + + Failed to fetch from "{0}" + Shown in a StatusCenter card. + + + Fetching from "{0}" + Shown in a StatusCenter card. + + + Fetching from "{0}" + Shown in a StatusCenter card. + + + Canceled pulling from "{0}" + Shown in a StatusCenter card. + + + Canceled pulling branch "{0}" from "{1}" + Shown in a StatusCenter card. + + + Pulled from "{0}" + Shown in a StatusCenter card. + + + Pulled branch "{0}" from "{1}" + Shown in a StatusCenter card. + + + Error pulling from "{0}" + Shown in a StatusCenter card. + + + Failed to pull branch "{0}" from "{1}" + Shown in a StatusCenter card. + + + Pulling from "{0}" + Shown in a StatusCenter card. + + + Pulling branch "{0}" from "{1}" + Shown in a StatusCenter card. + Preparing the operation... Shown in a StatusCenter card. diff --git a/src/Files.App/Utils/Git/GitHelpers.cs b/src/Files.App/Utils/Git/GitHelpers.cs index e49032fa77fc..a27eaa254b1a 100644 --- a/src/Files.App/Utils/Git/GitHelpers.cs +++ b/src/Files.App/Utils/Git/GitHelpers.cs @@ -355,7 +355,7 @@ public static bool ValidateBranchNameForRepository(string branchName, string rep branch.FriendlyName.Equals(branchName, StringComparison.OrdinalIgnoreCase)); } - public static async void FetchOrigin(string? repositoryPath, CancellationToken cancellationToken = default) + public static async Task FetchOrigin(string? repositoryPath, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(repositoryPath)) return; @@ -363,57 +363,78 @@ public static async void FetchOrigin(string? repositoryPath, CancellationToken c using var repository = new Repository(repositoryPath); var signature = repository.Config.BuildSignature(DateTimeOffset.Now); - var token = CredentialsHelpers.GetPassword(GIT_RESOURCE_NAME, GIT_RESOURCE_USERNAME); - if (signature is not null && !string.IsNullOrWhiteSpace(token)) - { - _fetchOptions.CredentialsProvider = (url, user, cred) - => new UsernamePasswordCredentials - { - Username = signature.Name, - Password = token - }; - } + var remoteName = repository.Network.Remotes.FirstOrDefault()?.Name ?? "origin"; + + // Add Status Center Card + var banner = StatusCenterHelper.AddCard_GitFetch(remoteName, ReturnResult.InProgress); + var fsProgress = new StatusCenterItemProgressModel(banner.ProgressEventSource, enumerationCompleted: true, FileSystemStatusCode.InProgress); + + // Link CancellationTokens + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, banner.CancellationToken); + var token = linkedCts.Token; + + var fetchOptions = new FetchOptions + { + CredentialsProvider = CredentialsHandler, + OnTransferProgress = (progress) => + { + if (token.IsCancellationRequested) + return false; + + if (progress.TotalObjects > 0) + { + fsProgress.ItemsCount = progress.TotalObjects; + fsProgress.SetProcessedSize(progress.ReceivedBytes); + fsProgress.AddProcessedItemsCount(progress.ReceivedObjects); + fsProgress.Report((int)((progress.ReceivedObjects / (double)progress.TotalObjects) * 100)); + } + return true; + } + }; MainWindow.Instance.DispatcherQueue.TryEnqueue(() => { IsExecutingGitAction = true; }); - await DoGitOperationAsync(() => + var result = await DoGitOperationAsync(() => { - cancellationToken.ThrowIfCancellationRequested(); - - var result = GitOperationResult.Success; try { + token.ThrowIfCancellationRequested(); + + // Iterate remotes (though usually only one matters for progress, we'll fetch all) foreach (var remote in repository.Network.Remotes) { - cancellationToken.ThrowIfCancellationRequested(); + token.ThrowIfCancellationRequested(); LibGit2Sharp.Commands.Fetch( repository, remote.Name, remote.FetchRefSpecs.Select(rs => rs.Specification), - _fetchOptions, + fetchOptions, "git fetch updated a ref"); } - cancellationToken.ThrowIfCancellationRequested(); + token.ThrowIfCancellationRequested(); + return GitOperationResult.Success; } catch (Exception ex) { - result = IsAuthorizationException(ex) + return IsAuthorizationException(ex) ? GitOperationResult.AuthorizationError : GitOperationResult.GenericError; } - - return result; }); + StatusCenterViewModel.RemoveItem(banner); + StatusCenterHelper.AddCard_GitFetch( + remoteName, + result == GitOperationResult.Success ? ReturnResult.Success : ReturnResult.Failed); + MainWindow.Instance.DispatcherQueue.TryEnqueue(() => { - if (cancellationToken.IsCancellationRequested) - // Do nothing because the operation was cancelled and another fetch may be in progress + if (token.IsCancellationRequested) return; IsExecutingGitAction = false; @@ -431,17 +452,36 @@ public static async Task PullOriginAsync(string? repositoryPath) if (signature is null) return; - var token = CredentialsHelpers.GetPassword(GIT_RESOURCE_NAME, GIT_RESOURCE_USERNAME); - if (!string.IsNullOrWhiteSpace(token)) - { - _pullOptions.FetchOptions ??= _fetchOptions; - _pullOptions.FetchOptions.CredentialsProvider = (url, user, cred) - => new UsernamePasswordCredentials - { - Username = signature.Name, - Password = token - }; - } + var branchName = repository.Head.FriendlyName; + // We use 'origin' as generic remote name for display, though pull might use upstream + var remoteName = "origin"; + + // Add Status Center Card + var banner = StatusCenterHelper.AddCard_GitPull(remoteName, branchName, ReturnResult.InProgress); + var fsProgress = new StatusCenterItemProgressModel(banner.ProgressEventSource, enumerationCompleted: true, FileSystemStatusCode.InProgress); + var token = banner.CancellationToken; + + var pullOptions = new PullOptions + { + FetchOptions = new FetchOptions + { + CredentialsProvider = CredentialsHandler, + OnTransferProgress = (progress) => + { + if (token.IsCancellationRequested) + return false; + + if (progress.TotalObjects > 0) + { + fsProgress.ItemsCount = progress.TotalObjects; + fsProgress.SetProcessedSize(progress.ReceivedBytes); + fsProgress.AddProcessedItemsCount(progress.ReceivedObjects); + fsProgress.Report((int)((progress.ReceivedObjects / (double)progress.TotalObjects) * 100)); + } + return true; + } + } + }; MainWindow.Instance.DispatcherQueue.TryEnqueue(() => { @@ -452,10 +492,11 @@ public static async Task PullOriginAsync(string? repositoryPath) { try { + // LibGit2Sharp Pull doesn't take CancellationToken explicitly in all overloads, rely on callbacks LibGit2Sharp.Commands.Pull( repository, signature, - _pullOptions); + pullOptions); } catch (Exception ex) { @@ -467,6 +508,9 @@ public static async Task PullOriginAsync(string? repositoryPath) return GitOperationResult.Success; }); + StatusCenterViewModel.RemoveItem(banner); + StatusCenterHelper.AddCard_GitPull(remoteName, branchName, result == GitOperationResult.Success ? ReturnResult.Success : ReturnResult.Failed); + if (result is GitOperationResult.AuthorizationError) { await RequireGitAuthenticationAsync(); @@ -486,7 +530,11 @@ public static async Task PullOriginAsync(string? repositoryPath) MainWindow.Instance.DispatcherQueue.TryEnqueue(() => { - IsExecutingGitAction = false; + IsExecutingGitAction = false; + if (result == GitOperationResult.Success) + { + GitFetchCompleted?.Invoke(null, EventArgs.Empty); + } }); } @@ -500,21 +548,31 @@ public static async Task PushToOriginAsync(string? repositoryPath, string? branc if (signature is null) return; - var token = CredentialsHelpers.GetPassword(GIT_RESOURCE_NAME, GIT_RESOURCE_USERNAME); - if (string.IsNullOrWhiteSpace(token)) + // Add Status Center Card + var banner = StatusCenterHelper.AddCard_GitPush("origin", branchName, ReturnResult.InProgress); + var fsProgress = new StatusCenterItemProgressModel(banner.ProgressEventSource, enumerationCompleted: true, FileSystemStatusCode.InProgress); + banner.CancellationToken.Register(() => { - await RequireGitAuthenticationAsync(); - token = CredentialsHelpers.GetPassword(GIT_RESOURCE_NAME, GIT_RESOURCE_USERNAME); - } + // Handle cancellation if needed, though LibGit2Sharp might not support seamless cancellation mid-push easily without callback + }); var options = new PushOptions() { - CredentialsProvider = (url, user, cred) - => new UsernamePasswordCredentials - { - Username = signature.Name, - Password = token - } + CredentialsProvider = CredentialsHandler, + OnPushTransferProgress = (current, total, bytes) => + { + if (banner.CancellationToken.IsCancellationRequested) + return false; // Cancel + + if (total > 0) + { + fsProgress.ItemsCount = total; + fsProgress.SetProcessedSize(bytes); + fsProgress.AddProcessedItemsCount(1); // Not accurate but shows activity + fsProgress.Report((int)((current / (double)total) * 100)); + } + return true; + } }; MainWindow.Instance.DispatcherQueue.TryEnqueue(() => @@ -522,6 +580,7 @@ public static async Task PushToOriginAsync(string? repositoryPath, string? branc IsExecutingGitAction = true; }); + GitOperationResult result = GitOperationResult.GenericError; try { var branch = repository.Branches[branchName]; @@ -534,10 +593,13 @@ public static async Task PushToOriginAsync(string? repositoryPath, string? branc b => b.UpstreamBranch = branch.CanonicalName); } - var result = await DoGitOperationAsync(() => + result = await DoGitOperationAsync(() => { try { + if (banner.CancellationToken.IsCancellationRequested) + return GitOperationResult.GenericError; // Or Cancelled + repository.Network.Push(branch, options); } catch (Exception ex) @@ -558,9 +620,22 @@ public static async Task PushToOriginAsync(string? repositoryPath, string? branc _logger.LogWarning(ex.Message); } + // Remove InProgress Card + StatusCenterViewModel.RemoveItem(banner); + + // Add Result Card + StatusCenterHelper.AddCard_GitPush( + "origin", + branchName, + result == GitOperationResult.Success ? ReturnResult.Success : ReturnResult.Failed); + MainWindow.Instance.DispatcherQueue.TryEnqueue(() => { IsExecutingGitAction = false; + if (result == GitOperationResult.Success) + { + GitFetchCompleted?.Invoke(null, EventArgs.Empty); + } }); } @@ -858,25 +933,29 @@ private static bool IsAuthorizationException(Exception ex) { return ex.Message.Contains("status code: 401", StringComparison.OrdinalIgnoreCase) || - ex.Message.Contains("authentication replays", StringComparison.OrdinalIgnoreCase); + ex.Message.Contains("authentication replays", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("authentication failed", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("user cancelled", StringComparison.OrdinalIgnoreCase); } private static async Task DoGitOperationAsync(Func payload, bool useSemaphore = false) { - if (useSemaphore) - await GitOperationSemaphore.WaitAsync(); - else - await Task.Yield(); - - try - { - return (T)payload(); - } - finally + // Offload to background thread to prevent UI freeze + return await Task.Run(async () => { if (useSemaphore) - GitOperationSemaphore.Release(); - } + await GitOperationSemaphore.WaitAsync(); + + try + { + return (T)payload(); + } + finally + { + if (useSemaphore) + GitOperationSemaphore.Release(); + } + }); } /// @@ -891,11 +970,21 @@ public static (string RepoUrl, string RepoName) GetRepoInfo(string url) if (!match.Success) return (string.Empty, string.Empty); - string platform = match.Groups["domain"].Value; + string domain = match.Groups["domain"].Value; string userOrOrg = match.Groups["user"].Value; string repoName = match.Groups["repo"].Value; + string protocol = match.Groups["protocol"].Value; + + string repoUrl; + if (protocol.Contains("@")) + { + repoUrl = $"git@{domain}.com:{userOrOrg}/{repoName}.git"; + } + else + { + repoUrl = $"https://{domain}.com/{userOrOrg}/{repoName}"; + } - string repoUrl = $"https://{platform}.com/{userOrOrg}/{repoName}"; return (repoUrl, repoName); } @@ -923,6 +1012,7 @@ public static async Task CloneRepoAsync(string repoUrl, string repoName, string { FetchOptions = { + CredentialsProvider = CredentialsHandler, OnTransferProgress = progress => { banner.CancellationToken.ThrowIfCancellationRequested(); @@ -965,7 +1055,28 @@ public static async Task CloneRepoAsync(string repoUrl, string repoName, string ReturnResult.Failed); } - [GeneratedRegex(@"^(?:https?:\/\/)?(?:www\.)?(?github|gitlab)\.com\/(?[^\/]+)\/(?[^\/]+?)(?=\.git|\/|$)(?:\.git)?(?:\/)?", RegexOptions.IgnoreCase)] + [GeneratedRegex(@"^(?:(?https?:\/\/)?(?:www\.)?|(?git@))(?github|gitlab)\.com(?:\/|:)(?[^\/]+)\/(?[^\/]+?)(?=\.git|\/|$)(?:\.git)?(?:\/)?", RegexOptions.IgnoreCase)] private static partial Regex GitHubRepositoryRegex(); + + private static Credentials? CredentialsHandler(string url, string usernameFromUrl, SupportedCredentialTypes types) + { + if (types.HasFlag(SupportedCredentialTypes.UsernamePassword)) + { + var token = CredentialsHelpers.GetPassword(GIT_RESOURCE_NAME, GIT_RESOURCE_USERNAME); + if (!string.IsNullOrWhiteSpace(token)) + { + return new UsernamePasswordCredentials + { + Username = "Personal Access Token", + Password = token + }; + } + } + + // For SSH or other types, return null to let LibGit2/OpenSSH handle it natively (e.g. via ssh-agent) + return null; + } + + } } diff --git a/src/Files.App/Utils/StatusCenter/StatusCenterHelper.cs b/src/Files.App/Utils/StatusCenter/StatusCenterHelper.cs index 8605bdd12415..c6f0005a0363 100644 --- a/src/Files.App/Utils/StatusCenter/StatusCenterHelper.cs +++ b/src/Files.App/Utils/StatusCenter/StatusCenterHelper.cs @@ -538,6 +538,191 @@ public static StatusCenterItem AddCard_GitClone( } } + public static StatusCenterItem AddCard_GitPush( + string destination, + string branchName, + ReturnResult returnStatus, + long itemsCount = 0, + long totalSize = 0) + { + if (returnStatus == ReturnResult.Cancelled) + { + return _statusCenterViewModel.AddItem( + "StatusCenter_GitPushCanceled_Header", + "StatusCenter_GitPushCanceled_SubHeader", + ReturnResult.Cancelled, + FileOperationType.GitPush, + branchName.CreateEnumerable(), + destination.CreateEnumerable(), + false, + itemsCount, + totalSize); + } + else if (returnStatus == ReturnResult.InProgress) + { + return _statusCenterViewModel.AddItem( + "StatusCenter_GitPushInProgress_Header", + "StatusCenter_GitPushInProgress_SubHeader", + ReturnResult.InProgress, + FileOperationType.GitPush, + branchName.CreateEnumerable(), + destination.CreateEnumerable(), + true, + itemsCount, + totalSize, + new CancellationTokenSource()); + } + else if (returnStatus == ReturnResult.Success) + { + return _statusCenterViewModel.AddItem( + "StatusCenter_GitPushComplete_Header", + "StatusCenter_GitPushComplete_SubHeader", + ReturnResult.Success, + FileOperationType.GitPush, + branchName.CreateEnumerable(), + destination.CreateEnumerable(), + false, + itemsCount, + totalSize); + } + else + { + return _statusCenterViewModel.AddItem( + "StatusCenter_GitPushFailed_Header", + "StatusCenter_GitPushFailed_SubHeader", + ReturnResult.Failed, + FileOperationType.GitPush, + branchName.CreateEnumerable(), + destination.CreateEnumerable(), + false, + itemsCount, + totalSize); + } + } + + public static StatusCenterItem AddCard_GitFetch( + string remoteName, + ReturnResult returnStatus, + long itemsCount = 0, + long totalSize = 0) + { + if (returnStatus == ReturnResult.Cancelled) + { + return _statusCenterViewModel.AddItem( + "StatusCenter_GitFetchCanceled_Header", + "StatusCenter_GitFetchCanceled_SubHeader", + ReturnResult.Cancelled, + FileOperationType.GitFetch, + remoteName.CreateEnumerable(), + null, + false, + itemsCount, + totalSize); + } + else if (returnStatus == ReturnResult.InProgress) + { + return _statusCenterViewModel.AddItem( + "StatusCenter_GitFetchInProgress_Header", + "StatusCenter_GitFetchInProgress_SubHeader", + ReturnResult.InProgress, + FileOperationType.GitFetch, + remoteName.CreateEnumerable(), + null, + true, + itemsCount, + totalSize, + new CancellationTokenSource()); + } + else if (returnStatus == ReturnResult.Success) + { + return _statusCenterViewModel.AddItem( + "StatusCenter_GitFetchComplete_Header", + "StatusCenter_GitFetchComplete_SubHeader", + ReturnResult.Success, + FileOperationType.GitFetch, + remoteName.CreateEnumerable(), + null, + false, + itemsCount, + totalSize); + } + else + { + return _statusCenterViewModel.AddItem( + "StatusCenter_GitFetchFailed_Header", + "StatusCenter_GitFetchFailed_SubHeader", + ReturnResult.Failed, + FileOperationType.GitFetch, + remoteName.CreateEnumerable(), + null, + false, + itemsCount, + totalSize); + } + } + + public static StatusCenterItem AddCard_GitPull( + string destination, + string branchName, + ReturnResult returnStatus, + long itemsCount = 0, + long totalSize = 0) + { + if (returnStatus == ReturnResult.Cancelled) + { + return _statusCenterViewModel.AddItem( + "StatusCenter_GitPullCanceled_Header", + "StatusCenter_GitPullCanceled_SubHeader", + ReturnResult.Cancelled, + FileOperationType.GitPull, + branchName.CreateEnumerable(), + destination.CreateEnumerable(), + false, + itemsCount, + totalSize); + } + else if (returnStatus == ReturnResult.InProgress) + { + return _statusCenterViewModel.AddItem( + "StatusCenter_GitPullInProgress_Header", + "StatusCenter_GitPullInProgress_SubHeader", + ReturnResult.InProgress, + FileOperationType.GitPull, + branchName.CreateEnumerable(), + destination.CreateEnumerable(), + true, + itemsCount, + totalSize, + new CancellationTokenSource()); + } + else if (returnStatus == ReturnResult.Success) + { + return _statusCenterViewModel.AddItem( + "StatusCenter_GitPullComplete_Header", + "StatusCenter_GitPullComplete_SubHeader", + ReturnResult.Success, + FileOperationType.GitPull, + branchName.CreateEnumerable(), + destination.CreateEnumerable(), + false, + itemsCount, + totalSize); + } + else + { + return _statusCenterViewModel.AddItem( + "StatusCenter_GitPullFailed_Header", + "StatusCenter_GitPullFailed_SubHeader", + ReturnResult.Failed, + FileOperationType.GitPull, + branchName.CreateEnumerable(), + destination.CreateEnumerable(), + false, + itemsCount, + totalSize); + } + } + public static StatusCenterItem AddCard_InstallFont( IEnumerable source, ReturnResult returnStatus, @@ -755,6 +940,40 @@ public static void UpdateCardStrings(StatusCenterItem card, StatusCenterItemProg card.SubHeader = subHeaderString; break; } + + case FileOperationType.GitPush: + { + string headerString = string.IsNullOrWhiteSpace(card.HeaderStringResource) ? string.Empty + : card.HeaderStringResource.GetLocalizedFormatResource(destinationDirName); + card.Header = headerString; + + string subHeaderString = string.IsNullOrWhiteSpace(card.SubHeaderStringResource) ? string.Empty + : card.SubHeaderStringResource.GetLocalizedFormatResource(sourceFileName, destinationDirName); + card.SubHeader = subHeaderString; + break; + } + case FileOperationType.GitFetch: + { + string headerString = string.IsNullOrWhiteSpace(card.HeaderStringResource) ? string.Empty + : card.HeaderStringResource.GetLocalizedFormatResource(sourceFileName); + card.Header = headerString; + + string subHeaderString = string.IsNullOrWhiteSpace(card.SubHeaderStringResource) ? string.Empty + : card.SubHeaderStringResource.GetLocalizedFormatResource(sourceFileName); + card.SubHeader = subHeaderString; + break; + } + case FileOperationType.GitPull: + { + string headerString = string.IsNullOrWhiteSpace(card.HeaderStringResource) ? string.Empty + : card.HeaderStringResource.GetLocalizedFormatResource(destinationDirName); + card.Header = headerString; + + string subHeaderString = string.IsNullOrWhiteSpace(card.SubHeaderStringResource) ? string.Empty + : card.SubHeaderStringResource.GetLocalizedFormatResource(sourceFileName, destinationDirName); + card.SubHeader = subHeaderString; + break; + } } } } From f0be6927a0e7aa080284edffabcd058e351d881a Mon Sep 17 00:00:00 2001 From: NyanHeart Date: Fri, 16 Jan 2026 16:25:50 +0800 Subject: [PATCH 2/2] Fix: Address PR review comments (threading, cleanup & error handling) - Threading: Wrapped Status Center UI updates and dialogs in `DispatcherQueue.TryEnqueue` to prevent `COMException` (RPC_E_WRONG_THREAD) during background fetches. - Cleanup: Removed unused `_fetchOptions` field and restored `Prune = true` for fetch operations. - Error Handling: - Catch `CheckoutConflictException` specifically for uncommitted changes during pull, providing a helpful localized error message. - Catch generic `LibGit2SharpException` to surface authentic underlying errors (e.g. ssh-agent failures, missing files) to the user via dialog. - Localization: Added `GitError_UncommittedChanges` resource string. --- src/Files.App/Strings/en-US/Resources.resw | 5 +- src/Files.App/Utils/Git/GitHelpers.cs | 243 +++++++++++++-------- 2 files changed, 153 insertions(+), 95 deletions(-) diff --git a/src/Files.App/Strings/en-US/Resources.resw b/src/Files.App/Strings/en-US/Resources.resw index 74309405b274..c27ca00e9012 100644 --- a/src/Files.App/Strings/en-US/Resources.resw +++ b/src/Files.App/Strings/en-US/Resources.resw @@ -3823,7 +3823,10 @@ Shown in a StatusCenter card. - Failed to pull branch "{0}" from "{1}" + We couldn't pull the latest changes from the remote right now. + + + Please commit or stash your changes before pulling. Shown in a StatusCenter card. diff --git a/src/Files.App/Utils/Git/GitHelpers.cs b/src/Files.App/Utils/Git/GitHelpers.cs index a27eaa254b1a..1ab18f33ed1d 100644 --- a/src/Files.App/Utils/Git/GitHelpers.cs +++ b/src/Files.App/Utils/Git/GitHelpers.cs @@ -29,11 +29,6 @@ internal static partial class GitHelpers private static readonly IDialogService _dialogService = Ioc.Default.GetRequiredService(); - private static readonly FetchOptions _fetchOptions = new() - { - Prune = true - }; - private static readonly PullOptions _pullOptions = new(); private static readonly string _clientId = AppLifecycleHelper.AppEnvironment is AppEnvironment.Dev @@ -363,34 +358,39 @@ public static async Task FetchOrigin(string? repositoryPath, CancellationToken c using var repository = new Repository(repositoryPath); var signature = repository.Config.BuildSignature(DateTimeOffset.Now); - var remoteName = repository.Network.Remotes.FirstOrDefault()?.Name ?? "origin"; - - // Add Status Center Card - var banner = StatusCenterHelper.AddCard_GitFetch(remoteName, ReturnResult.InProgress); - var fsProgress = new StatusCenterItemProgressModel(banner.ProgressEventSource, enumerationCompleted: true, FileSystemStatusCode.InProgress); - - // Link CancellationTokens - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, banner.CancellationToken); - var token = linkedCts.Token; - - var fetchOptions = new FetchOptions - { - CredentialsProvider = CredentialsHandler, - OnTransferProgress = (progress) => - { - if (token.IsCancellationRequested) - return false; - - if (progress.TotalObjects > 0) - { - fsProgress.ItemsCount = progress.TotalObjects; - fsProgress.SetProcessedSize(progress.ReceivedBytes); - fsProgress.AddProcessedItemsCount(progress.ReceivedObjects); - fsProgress.Report((int)((progress.ReceivedObjects / (double)progress.TotalObjects) * 100)); - } - return true; - } - }; + var remoteName = repository.Network.Remotes.FirstOrDefault()?.Name ?? "origin"; + + // Add Status Center Card + var banner = StatusCenterHelper.AddCard_GitFetch(remoteName, ReturnResult.InProgress); + var fsProgress = new StatusCenterItemProgressModel(banner.ProgressEventSource, enumerationCompleted: true, FileSystemStatusCode.InProgress); + + // Link CancellationTokens + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, banner.CancellationToken); + var token = linkedCts.Token; + + var fetchOptions = new FetchOptions + { + CredentialsProvider = CredentialsHandler, + OnTransferProgress = (progress) => + { + if (token.IsCancellationRequested) + return false; + + // Ensure StatusCenter updates are dispatched to the UI thread + MainWindow.Instance.DispatcherQueue.TryEnqueue(() => + { + if (progress.TotalObjects > 0) + { + fsProgress.ItemsCount = progress.TotalObjects; + fsProgress.SetProcessedSize(progress.ReceivedBytes); + fsProgress.AddProcessedItemsCount(progress.ReceivedObjects); + fsProgress.Report((int)((progress.ReceivedObjects / (double)progress.TotalObjects) * 100)); + } + }); + return true; + }, + Prune = true // Restore Prune behavior as requested in PR review + }; MainWindow.Instance.DispatcherQueue.TryEnqueue(() => { @@ -403,7 +403,7 @@ public static async Task FetchOrigin(string? repositoryPath, CancellationToken c { token.ThrowIfCancellationRequested(); - // Iterate remotes (though usually only one matters for progress, we'll fetch all) + // Iterate remotes (though usually only one matters for progress, we'll fetch all) foreach (var remote in repository.Network.Remotes) { token.ThrowIfCancellationRequested(); @@ -417,7 +417,22 @@ public static async Task FetchOrigin(string? repositoryPath, CancellationToken c } token.ThrowIfCancellationRequested(); - return GitOperationResult.Success; + return GitOperationResult.Success; + } + catch (LibGit2SharpException e) + { + MainWindow.Instance.DispatcherQueue.TryEnqueue(async () => + { + var dialog = new DynamicDialog(new DynamicDialogViewModel + { + TitleText = Strings.GitError.GetLocalizedResource(), + SubtitleText = e.Message, + CloseButtonText = Strings.Close.GetLocalizedResource(), + DynamicButtons = DynamicDialogButtons.Cancel + }); + await dialog.TryShowAsync(); + }); + return GitOperationResult.GenericError; } catch (Exception ex) { @@ -427,10 +442,13 @@ public static async Task FetchOrigin(string? repositoryPath, CancellationToken c } }); - StatusCenterViewModel.RemoveItem(banner); - StatusCenterHelper.AddCard_GitFetch( - remoteName, - result == GitOperationResult.Success ? ReturnResult.Success : ReturnResult.Failed); + MainWindow.Instance.DispatcherQueue.TryEnqueue(() => + { + StatusCenterViewModel.RemoveItem(banner); + StatusCenterHelper.AddCard_GitFetch( + remoteName, + result == GitOperationResult.Success ? ReturnResult.Success : ReturnResult.Failed); + }); MainWindow.Instance.DispatcherQueue.TryEnqueue(() => { @@ -452,36 +470,40 @@ public static async Task PullOriginAsync(string? repositoryPath) if (signature is null) return; - var branchName = repository.Head.FriendlyName; - // We use 'origin' as generic remote name for display, though pull might use upstream - var remoteName = "origin"; - - // Add Status Center Card - var banner = StatusCenterHelper.AddCard_GitPull(remoteName, branchName, ReturnResult.InProgress); - var fsProgress = new StatusCenterItemProgressModel(banner.ProgressEventSource, enumerationCompleted: true, FileSystemStatusCode.InProgress); - var token = banner.CancellationToken; - - var pullOptions = new PullOptions - { - FetchOptions = new FetchOptions - { - CredentialsProvider = CredentialsHandler, - OnTransferProgress = (progress) => - { - if (token.IsCancellationRequested) - return false; - - if (progress.TotalObjects > 0) - { - fsProgress.ItemsCount = progress.TotalObjects; - fsProgress.SetProcessedSize(progress.ReceivedBytes); - fsProgress.AddProcessedItemsCount(progress.ReceivedObjects); - fsProgress.Report((int)((progress.ReceivedObjects / (double)progress.TotalObjects) * 100)); - } - return true; - } - } - }; + var branchName = repository.Head.FriendlyName; + // We use 'origin' as generic remote name for display, though pull might use upstream + var remoteName = "origin"; + + // Add Status Center Card + var banner = StatusCenterHelper.AddCard_GitPull(remoteName, branchName, ReturnResult.InProgress); + var fsProgress = new StatusCenterItemProgressModel(banner.ProgressEventSource, enumerationCompleted: true, FileSystemStatusCode.InProgress); + var token = banner.CancellationToken; + + var pullOptions = new PullOptions + { + FetchOptions = new FetchOptions + { + CredentialsProvider = CredentialsHandler, + OnTransferProgress = (progress) => + { + if (token.IsCancellationRequested) + return false; + + // Ensure StatusCenter updates are dispatched to the UI thread + MainWindow.Instance.DispatcherQueue.TryEnqueue(() => + { + if (progress.TotalObjects > 0) + { + fsProgress.ItemsCount = progress.TotalObjects; + fsProgress.SetProcessedSize(progress.ReceivedBytes); + fsProgress.AddProcessedItemsCount(progress.ReceivedObjects); + fsProgress.Report((int)((progress.ReceivedObjects / (double)progress.TotalObjects) * 100)); + } + }); + return true; + } + } + }; MainWindow.Instance.DispatcherQueue.TryEnqueue(() => { @@ -492,12 +514,42 @@ public static async Task PullOriginAsync(string? repositoryPath) { try { - // LibGit2Sharp Pull doesn't take CancellationToken explicitly in all overloads, rely on callbacks + // LibGit2Sharp Pull doesn't take CancellationToken explicitly in all overloads, rely on callbacks LibGit2Sharp.Commands.Pull( repository, signature, pullOptions); } + catch (CheckoutConflictException) + { + MainWindow.Instance.DispatcherQueue.TryEnqueue(async () => + { + var dialog = new DynamicDialog(new DynamicDialogViewModel + { + TitleText = Strings.GitError.GetLocalizedResource(), + SubtitleText = Strings.GitError_UncommittedChanges.GetLocalizedResource(), + CloseButtonText = Strings.Close.GetLocalizedResource(), + DynamicButtons = DynamicDialogButtons.Cancel + }); + await dialog.TryShowAsync(); + }); + return GitOperationResult.GenericError; + } + catch (LibGit2SharpException e) + { + MainWindow.Instance.DispatcherQueue.TryEnqueue(async () => + { + var dialog = new DynamicDialog(new DynamicDialogViewModel + { + TitleText = Strings.GitError.GetLocalizedResource(), + SubtitleText = e.Message, + CloseButtonText = Strings.Close.GetLocalizedResource(), + DynamicButtons = DynamicDialogButtons.Cancel + }); + await dialog.TryShowAsync(); + }); + return GitOperationResult.GenericError; + } catch (Exception ex) { return IsAuthorizationException(ex) @@ -508,8 +560,11 @@ public static async Task PullOriginAsync(string? repositoryPath) return GitOperationResult.Success; }); - StatusCenterViewModel.RemoveItem(banner); - StatusCenterHelper.AddCard_GitPull(remoteName, branchName, result == GitOperationResult.Success ? ReturnResult.Success : ReturnResult.Failed); + MainWindow.Instance.DispatcherQueue.TryEnqueue(() => + { + StatusCenterViewModel.RemoveItem(banner); + StatusCenterHelper.AddCard_GitPull(remoteName, branchName, result == GitOperationResult.Success ? ReturnResult.Success : ReturnResult.Failed); + }); if (result is GitOperationResult.AuthorizationError) { @@ -530,11 +585,11 @@ public static async Task PullOriginAsync(string? repositoryPath) MainWindow.Instance.DispatcherQueue.TryEnqueue(() => { - IsExecutingGitAction = false; - if (result == GitOperationResult.Success) - { - GitFetchCompleted?.Invoke(null, EventArgs.Empty); - } + IsExecutingGitAction = false; + if (result == GitOperationResult.Success) + { + GitFetchCompleted?.Invoke(null, EventArgs.Empty); + } }); } @@ -564,13 +619,13 @@ public static async Task PushToOriginAsync(string? repositoryPath, string? branc if (banner.CancellationToken.IsCancellationRequested) return false; // Cancel - if (total > 0) - { - fsProgress.ItemsCount = total; - fsProgress.SetProcessedSize(bytes); - fsProgress.AddProcessedItemsCount(1); // Not accurate but shows activity - fsProgress.Report((int)((current / (double)total) * 100)); - } + if (total > 0) + { + fsProgress.ItemsCount = total; + fsProgress.SetProcessedSize(bytes); + fsProgress.AddProcessedItemsCount(1); // Not accurate but shows activity + fsProgress.Report((int)((current / (double)total) * 100)); + } return true; } }; @@ -598,7 +653,7 @@ public static async Task PushToOriginAsync(string? repositoryPath, string? branc try { if (banner.CancellationToken.IsCancellationRequested) - return GitOperationResult.GenericError; // Or Cancelled + return GitOperationResult.GenericError; // Or Cancelled repository.Network.Push(branch, options); } @@ -620,22 +675,22 @@ public static async Task PushToOriginAsync(string? repositoryPath, string? branc _logger.LogWarning(ex.Message); } - // Remove InProgress Card + // Remove InProgress Card StatusCenterViewModel.RemoveItem(banner); // Add Result Card - StatusCenterHelper.AddCard_GitPush( - "origin", - branchName, - result == GitOperationResult.Success ? ReturnResult.Success : ReturnResult.Failed); + StatusCenterHelper.AddCard_GitPush( + "origin", + branchName, + result == GitOperationResult.Success ? ReturnResult.Success : ReturnResult.Failed); MainWindow.Instance.DispatcherQueue.TryEnqueue(() => { IsExecutingGitAction = false; - if (result == GitOperationResult.Success) - { - GitFetchCompleted?.Invoke(null, EventArgs.Empty); - } + if (result == GitOperationResult.Success) + { + GitFetchCompleted?.Invoke(null, EventArgs.Empty); + } }); }