Add Repository Filter (InstallerWindow)

Introduces a repository filter dropdown to the PluginInstallerWindow, allowing users to filter plugins by their source repository.
This commit is contained in:
Jerric 2025-12-25 14:42:09 -05:00
parent 682b932a11
commit 97748072f5

View file

@ -126,6 +126,11 @@ internal class PluginInstallerWindow : Window, IDisposable
private string filterText = Locs.SortBy_Alphabetical; private string filterText = Locs.SortBy_Alphabetical;
private bool adaptiveSort = true; private bool adaptiveSort = true;
private string? selectedRepoUrl = null; // null = All Repositories
private List<string> cachedRepoUrls = new();
private HashSet<string> cachedRepoUrlsNormalized = new(StringComparer.OrdinalIgnoreCase);
private const string XivLauncherRepoKey = "XIVLauncher";
private OperationStatus installStatus = OperationStatus.Idle; private OperationStatus installStatus = OperationStatus.Idle;
private OperationStatus updateStatus = OperationStatus.Idle; private OperationStatus updateStatus = OperationStatus.Idle;
@ -789,7 +794,64 @@ internal class PluginInstallerWindow : Window, IDisposable
ImGui.EndCombo(); ImGui.EndCombo();
} }
} }
}
// Repository filter (overarching filter for repositories)
this.RefreshRepoFilterList();
// Disabled for changelog views or profile editor (plugin collections).
var disableRepoFilter = this.categoryManager.CurrentGroupKind == PluginCategoryManager.GroupKind.Changelog || isProfileManager;
using (ImRaii.Disabled(disableRepoFilter))
{
ImGuiHelpers.ScaledDummy(5);
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("Repository Filter");
ImGui.SameLine();
// Fill remaining width of the header row
ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X);
var repoPreview = this.selectedRepoUrl ?? "All Repositories";
if (ImGui.BeginCombo("##RepoFilterCombo", repoPreview))
{
if (ImGui.Selectable("All Repositories", this.selectedRepoUrl == null))
{
this.selectedRepoUrl = null;
this.UpdateCategoriesOnPluginsChange();
this.openPluginCollapsibles.Clear();
}
if (ImGui.Selectable("XIVLauncher", this.selectedRepoUrl == XivLauncherRepoKey))
{
this.selectedRepoUrl = XivLauncherRepoKey;
this.UpdateCategoriesOnPluginsChange();
this.openPluginCollapsibles.Clear();
}
ImGui.Separator();
for (var i = 0; i < this.cachedRepoUrls.Count; i++)
{
var repoUrl = this.cachedRepoUrls[i];
var selected = this.selectedRepoUrl == repoUrl;
// Use stable unique IDs to avoid ImGui collisions with long/duplicate URLs.
if (ImGui.Selectable($"{repoUrl}##repo_{i}", selected))
{
this.selectedRepoUrl = repoUrl;
this.UpdateCategoriesOnPluginsChange();
this.openPluginCollapsibles.Clear();
}
}
ImGui.EndCombo();
}
ImGuiHelpers.ScaledDummy(10);
}
}
private void DrawFooter() private void DrawFooter()
{ {
@ -1306,6 +1368,7 @@ internal class PluginInstallerWindow : Window, IDisposable
var filteredAvailableManifests = availableManifests var filteredAvailableManifests = availableManifests
.Where(rm => !this.IsManifestFiltered(rm)) .Where(rm => !this.IsManifestFiltered(rm))
.Where(this.PassesRepoFilter)
.ToList(); .ToList();
if (filteredAvailableManifests.Count == 0) if (filteredAvailableManifests.Count == 0)
@ -1318,7 +1381,7 @@ internal class PluginInstallerWindow : Window, IDisposable
{ {
var plugin = this.pluginListInstalled var plugin = this.pluginListInstalled
.FirstOrDefault(plugin => plugin.Manifest.InternalName == availableManifest.InternalName && .FirstOrDefault(plugin => plugin.Manifest.InternalName == availableManifest.InternalName &&
plugin.Manifest.RepoUrl == availableManifest.RepoUrl && RepoUrlMatches(GetRepoFilterUrl(plugin), GetRepoFilterUrl(availableManifest)) &&
!plugin.IsDev); !plugin.IsDev);
// We "consumed" this plugin from the pile and remove it. // We "consumed" this plugin from the pile and remove it.
@ -1339,6 +1402,9 @@ internal class PluginInstallerWindow : Window, IDisposable
if (this.IsManifestFiltered(installedPlugin.Manifest)) if (this.IsManifestFiltered(installedPlugin.Manifest))
continue; continue;
if (!this.PassesRepoFilter(GetRepoFilterUrl(installedPlugin)))
continue;
// TODO: We should also check categories here, for good measure // TODO: We should also check categories here, for good measure
proxies.Add(new PluginInstallerAvailablePluginProxy(null, installedPlugin)); proxies.Add(new PluginInstallerAvailablePluginProxy(null, installedPlugin));
@ -1441,6 +1507,7 @@ internal class PluginInstallerWindow : Window, IDisposable
var filteredList = pluginList var filteredList = pluginList
.Where(plugin => !this.IsManifestFiltered(plugin.Manifest)) .Where(plugin => !this.IsManifestFiltered(plugin.Manifest))
.Where(plugin => this.PassesRepoFilter(!string.IsNullOrWhiteSpace(plugin.Manifest.InstalledFromUrl) ? plugin.Manifest.InstalledFromUrl : plugin.Manifest.RepoUrl))
.ToList(); .ToList();
if (filteredList.Count == 0) if (filteredList.Count == 0)
@ -1478,7 +1545,7 @@ internal class PluginInstallerWindow : Window, IDisposable
// Find the applicable remote manifest // Find the applicable remote manifest
remoteManifest = this.pluginListAvailable remoteManifest = this.pluginListAvailable
.FirstOrDefault(rm => rm.InternalName == plugin.Manifest.InternalName && .FirstOrDefault(rm => rm.InternalName == plugin.Manifest.InternalName &&
rm.RepoUrl == plugin.Manifest.RepoUrl); RepoUrlMatches(rm.RepoUrl, plugin.Manifest.RepoUrl));
} }
else if (!plugin.IsDev) else if (!plugin.IsDev)
{ {
@ -3976,12 +4043,12 @@ internal class PluginInstallerWindow : Window, IDisposable
} }
else else
{ {
var pluginsMatchingSearch = this.pluginListAvailable.Where(rm => !this.IsManifestFiltered(rm)).ToArray(); var pluginsMatchingSearch = this.pluginListAvailable.Where(rm => this.PassesRepoFilter(rm) && !this.IsManifestFiltered(rm)).ToArray();
// Check if the search results are different, and clear the open collapsibles if they are // Check if the search results are different, and clear the open collapsibles if they are
if (previousSearchText != null) if (previousSearchText != null)
{ {
var previousSearchResults = this.pluginListAvailable.Where(rm => !this.IsManifestFiltered(rm)).ToArray(); var previousSearchResults = this.pluginListAvailable.Where(rm => this.PassesRepoFilter(rm) && !this.IsManifestFiltered(rm)).ToArray();
if (!previousSearchResults.SequenceEqual(pluginsMatchingSearch)) if (!previousSearchResults.SequenceEqual(pluginsMatchingSearch))
this.openPluginCollapsibles.Clear(); this.openPluginCollapsibles.Clear();
} }
@ -3992,10 +4059,123 @@ internal class PluginInstallerWindow : Window, IDisposable
private void UpdateCategoriesOnPluginsChange() private void UpdateCategoriesOnPluginsChange()
{ {
this.categoryManager.BuildCategories(this.pluginListAvailable); this.categoryManager.BuildCategories(this.pluginListAvailable.Where(this.PassesRepoFilter).ToList());
this.UpdateCategoriesOnSearchChange(null); this.UpdateCategoriesOnSearchChange(null);
} }
private void RefreshRepoFilterList()
{
var config = Service<DalamudConfiguration>.Get();
this.cachedRepoUrls.Clear();
this.cachedRepoUrlsNormalized.Clear();
// Main repo is represented by the "XIVLauncher" entry; here we only list third-party repos.
var seenDisplay = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var repo in config.ThirdRepoList)
{
if (string.IsNullOrWhiteSpace(repo.Url))
continue;
var trimmed = repo.Url.Trim();
if (!seenDisplay.Add(trimmed))
continue;
this.cachedRepoUrls.Add(trimmed);
var normalized = NormalizeRepoUrl(trimmed);
if (!string.IsNullOrEmpty(normalized))
this.cachedRepoUrlsNormalized.Add(normalized);
}
}
private static string NormalizeRepoUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url))
return string.Empty;
url = url.Trim();
// Best-effort URI normalization: ignore query/fragment; compare by path.
if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
var left = uri.GetLeftPart(UriPartial.Path);
return left.TrimEnd('/');
}
return url.TrimEnd('/');
}
private static bool RepoUrlMatches(string? a, string? b)
{
var na = NormalizeRepoUrl(a);
var nb = NormalizeRepoUrl(b);
if (string.IsNullOrEmpty(na) || string.IsNullOrEmpty(nb))
return false;
if (string.Equals(na, nb, StringComparison.OrdinalIgnoreCase))
return true;
// Allow prefix matches to handle minor formatting differences (e.g. /api/6 suffix).
// Ensure the prefix boundary is a path separator.
if (na.Length < nb.Length && nb.StartsWith(na, StringComparison.OrdinalIgnoreCase))
return nb[na.Length] == '/';
if (nb.Length < na.Length && na.StartsWith(nb, StringComparison.OrdinalIgnoreCase))
return na[nb.Length] == '/';
return false;
}
private static string? GetRepoFilterUrl(RemotePluginManifest manifest)
{
// For available manifests, RepoUrl is often the plugin's project URL (or null).
// The repository identity we care about is the *source repo* (pluginmaster URL) vs XIVLauncher (main repo).
if (manifest.SourceRepo != null && manifest.SourceRepo.IsThirdParty)
return manifest.SourceRepo.PluginMasterUrl;
return PluginRepository.MainRepoUrl;
}
private static string? GetRepoFilterUrl(LocalPlugin plugin)
{
// Installed third-party plugins store their origin in InstalledFromUrl.
if (plugin.IsThirdParty)
return plugin.Manifest.InstalledFromUrl;
return PluginRepository.MainRepoUrl;
}
private bool PassesRepoFilter(string? repoUrl)
{
// null => All Repositories
if (this.selectedRepoUrl == null)
return true;
// Anything not from the user's third-party repo list
if (this.selectedRepoUrl == XivLauncherRepoKey)
{
// Treat empty and the known main repo URL as "XIVLauncher"
if (string.IsNullOrWhiteSpace(repoUrl) || RepoUrlMatches(repoUrl, PluginRepository.MainRepoUrl))
return true;
var normalized = NormalizeRepoUrl(repoUrl);
if (string.IsNullOrEmpty(normalized))
return true;
// Anything not matching the configured third-party repos is considered "XIVLauncher".
return !this.cachedRepoUrlsNormalized.Any(u => RepoUrlMatches(u, normalized) || string.Equals(u, normalized, StringComparison.OrdinalIgnoreCase));
}
return RepoUrlMatches(repoUrl, this.selectedRepoUrl);
}
private bool PassesRepoFilter(RemotePluginManifest manifest) => this.PassesRepoFilter(GetRepoFilterUrl(manifest));
private void DrawFontawesomeIconOutlined(FontAwesomeIcon icon, Vector4 outline, Vector4 iconColor) private void DrawFontawesomeIconOutlined(FontAwesomeIcon icon, Vector4 outline, Vector4 iconColor)
{ {
var positionOffset = ImGuiHelpers.ScaledVector2(0.0f, 1.0f); var positionOffset = ImGuiHelpers.ScaledVector2(0.0f, 1.0f);