From 50ce5b556ef7601e0392d54da70dea9041fcc9e8 Mon Sep 17 00:00:00 2001
From: JC-Chung <52159296+JC-Chung@users.noreply.github.com>
Date: Thu, 22 Oct 2026 10:24:36 +0800
Subject: [PATCH] feat: add "fetch all repositories" action in workspace menu
update feature
- `Repository.FetchAllRemotesAsync()` now return `bool` (success/failure)
- `Launcher.FetchAllRepositoriesAsync()` now sends a summary notification
(`Fetched X/Y repositories`) to the active page when done.
show no remote repositories
show failed repositories
show skipped repositories
---
src/Resources/Locales/en_US.axaml | 2 +
src/Resources/Locales/zh_CN.axaml | 1 +
src/Resources/Locales/zh_TW.axaml | 2 +
src/ViewModels/Launcher.cs | 98 +++++++++++++++++++++++++++++++
src/ViewModels/Repository.cs | 52 ++++++++++++++++
src/Views/Launcher.axaml.cs | 25 ++++++++
6 files changed, 180 insertions(+)
diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml
index d3ffb203a4..8c3ffa6342 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 90eb61af95..c48adce663 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 d59bd7d7c3..2836b5bd27 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 aad821cda9..9992850071 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 1ea5951a83..deea22ea82 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 0ac14e6bb3..92ee1b6c2a 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) =>