diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index d3ffb203a..8c3ffa634 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -1078,6 +1078,8 @@ Template: ${0}$ WORKSPACE: Configure Workspaces... + Fetch All Repositories + Fetch All Repositories (Parallel) WORKTREE BRANCH Copy Path diff --git a/src/Resources/Locales/zh_CN.axaml b/src/Resources/Locales/zh_CN.axaml index 90eb61af9..c48adce66 100644 --- a/src/Resources/Locales/zh_CN.axaml +++ b/src/Resources/Locales/zh_CN.axaml @@ -1082,6 +1082,7 @@ 模板:${0}$ 工作区: 配置工作区... + 拉取工作区所有仓库 本地工作树 連結分支 复制工作树路径 diff --git a/src/Resources/Locales/zh_TW.axaml b/src/Resources/Locales/zh_TW.axaml index d59bd7d7c..2836b5bd2 100644 --- a/src/Resources/Locales/zh_TW.axaml +++ b/src/Resources/Locales/zh_TW.axaml @@ -1081,6 +1081,8 @@ 範本: ${0}$ 工作區: 設定工作區... + 提取工作區所有存放庫 + 提取工作區所有存放庫 (併行) 本機工作樹 分支 複製工作樹路徑 diff --git a/src/ViewModels/Launcher.cs b/src/ViewModels/Launcher.cs index aad821cda..999285007 100644 --- a/src/ViewModels/Launcher.cs +++ b/src/ViewModels/Launcher.cs @@ -1,5 +1,8 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Threading; +using System.Threading.Tasks; using Avalonia.Collections; using Avalonia.Threading; @@ -120,6 +123,101 @@ public void CloseAll() _ignoreIndexChange = false; } + public Task FetchAllRepositoriesAsync() + => FetchAllRepositoriesCoreAsync(parallel: false); + + public Task FetchAllRepositoriesInParallelAsync() + => FetchAllRepositoriesCoreAsync(parallel: true); + + private async Task FetchAllRepositoriesCoreAsync(bool parallel) + { + var targets = new List<(string Name, Repository Repo)>(); + var noRemoteNames = new List(); + + foreach (var page in Pages) + { + if (page.Data is not Repository repo) + continue; + + if (repo.Remotes.Count == 0) + noRemoteNames.Add(page.Node.Name); + else + targets.Add((page.Node.Name, repo)); + } + + var results = new List<(string Name, Repository.FetchAllRemotesResult Result)>(); + + if (parallel) + { + const int MaxConcurrentFetches = 5; + using var semaphore = new SemaphoreSlim(MaxConcurrentFetches); + + var tasks = new List>(targets.Count); + foreach (var (_, Repo) in targets) + tasks.Add(RunThrottledAsync(Repo, semaphore)); + + await Task.WhenAll(tasks).ConfigureAwait(false); + + for (var i = 0; i < targets.Count; i++) + results.Add((targets[i].Name, tasks[i].Result)); + } + else + { + foreach (var (name, repo) in targets) + results.Add((name, await repo.FetchAllRemotesAsync())); + } + + var total = 0; + var succeeded = 0; + var skippedNames = new List(); + var failedNames = new List(); + + foreach (var (name, result) in results) + { + switch (result.Status) + { + case Repository.FetchAllStatus.Succeeded: + total++; + succeeded++; + break; + case Repository.FetchAllStatus.Skipped: + skippedNames.Add(name); + break; + case Repository.FetchAllStatus.Failed: + total++; + failedNames.Add($"{name} (remote: {string.Join(", ", result.FailedRemotes)})"); + break; + } + } + + var message = $"Fetched {succeeded}/{total} repositories"; + + if (noRemoteNames.Count > 0) + message += $"\n{noRemoteNames.Count} repositories skipped (no remote):\n {string.Join("\n ", noRemoteNames)}"; + + if (skippedNames.Count > 0) + message += $"\n{skippedNames.Count} repositories skipped:\n {string.Join("\n ", skippedNames)}"; + + if (failedNames.Count > 0) + message += $"\n{failedNames.Count} repositories failed:\n {string.Join("\n ", failedNames)}"; + + Models.Notification.Send(null, message, succeeded < total); + } + + private static async Task RunThrottledAsync( + Repository repo, SemaphoreSlim semaphore) + { + await semaphore.WaitAsync(); + try + { + return await repo.FetchAllRemotesAsync(); + } + finally + { + semaphore.Release(); + } + } + public void SwitchWorkspace(Workspace to) { if (to == null || to.IsActive) diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs index 1ea5951a8..deea22ea8 100644 --- a/src/ViewModels/Repository.cs +++ b/src/ViewModels/Repository.cs @@ -667,6 +667,58 @@ public async Task FetchAsync(bool autoStart) ShowPopup(new Fetch(this)); } + public enum FetchAllStatus + { + Succeeded, + Failed, + Skipped, + } + + public class FetchAllRemotesResult + { + public FetchAllStatus Status { get; set; } = FetchAllStatus.Succeeded; + public List FailedRemotes { get; } = new List(); + } + + public async Task FetchAllRemotesAsync() + { + var result = new FetchAllRemotesResult(); + + if (IsAutoFetching) + { + result.Status = FetchAllStatus.Skipped; + return result; + } + + CommandLog log = null; + + try + { + IsAutoFetching = true; + log = CreateLog("Fetch"); + + foreach (var remote in _remotes) + { + var succ = await new Commands.Fetch(FullPath, remote).Use(log).ExecAsync(); + if (!succ) + result.FailedRemotes.Add(remote.Name); + } + + _lastFetchTime = DateTime.Now; + } + catch + { + // Ignore all exceptions. + result.FailedRemotes.Add("*"); + } + + IsAutoFetching = false; + log?.Complete(); + + result.Status = result.FailedRemotes.Count == 0 ? FetchAllStatus.Succeeded : FetchAllStatus.Failed; + return result; + } + public async Task PullAsync(bool autoStart) { if (IsBare || !CanCreatePopup()) diff --git a/src/Views/Launcher.axaml.cs b/src/Views/Launcher.axaml.cs index 0ac14e6bb..92ee1b6c2 100644 --- a/src/Views/Launcher.axaml.cs +++ b/src/Views/Launcher.axaml.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using Avalonia; using Avalonia.Controls; @@ -405,6 +406,30 @@ private void OnOpenWorkspaceMenu(object sender, RoutedEventArgs e) menu.Items.Add(new MenuItem() { Header = "-" }); + var hasRepos = launcher.Pages.Any(p => p.Data is ViewModels.Repository); + + var fetchAll = new MenuItem(); + fetchAll.Header = App.Text("Workspace.FetchAllRepositories"); + fetchAll.Icon = this.CreateMenuIcon("Icons.Fetch"); + fetchAll.IsEnabled = hasRepos; + fetchAll.Click += async (_, ev) => + { + await launcher.FetchAllRepositoriesAsync(); + ev.Handled = true; + }; + menu.Items.Add(fetchAll); + + var fetchAllParallel = new MenuItem(); + fetchAllParallel.Header = App.Text("Workspace.FetchAllRepositoriesParallel"); + fetchAllParallel.Icon = this.CreateMenuIcon("Icons.Fetch"); + fetchAllParallel.IsEnabled = hasRepos; + fetchAllParallel.Click += async (_, ev) => + { + await launcher.FetchAllRepositoriesInParallelAsync(); + ev.Handled = true; + }; + menu.Items.Add(fetchAllParallel); + var configure = new MenuItem(); configure.Header = App.Text("Workspace.Configure"); configure.Click += async (_, ev) =>